From e8b8df37c1edd5c30833cd9c118f1b898ffbf3d0 Mon Sep 17 00:00:00 2001 From: Jarrett Lusso Date: Mon, 1 Jun 2026 18:09:11 -0400 Subject: [PATCH 1/2] Bring comments up to idiomatic Go doc-comment standards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document every exported type, func, method, const, and var with a terse, name-first doc comment, and keep a package comment on each package — matching the Go standard library and common Go practice (e.g. basecamp-cli). Trim the narration and multi-line rationale that had accumulated on internal and unexported code, keeping only comments that explain a non-obvious why: a gotcha, trade-off, or spec quirk. Drop the "comments are a last resort" rule from AGENTS.md; the committed code now sets the convention. Comments-only change — no code, logic, or output strings were modified. golangci-lint (default config, and revive's exported rule), go vet, build, and the full test suite all pass. --- AGENTS.md | 7 -- cmd/account.go | 1 - cmd/batch.go | 82 ++------------- cmd/context.go | 41 +------- cmd/email_inputs.go | 46 ++------- cmd/errors.go | 47 +-------- cmd/login.go | 68 ++----------- cmd/logout.go | 8 -- cmd/man.go | 6 -- cmd/root.go | 90 ++--------------- cmd/skill.go | 2 - cmd/status.go | 12 --- cmd/verify.go | 22 +--- cmd/version.go | 9 +- cmd/welcome.go | 50 ++------- internal/api/client.go | 77 +++++--------- internal/api/errors.go | 10 +- internal/api/types.go | 53 +++------- internal/config/config.go | 18 +--- internal/credentials/credentials.go | 41 ++------ internal/env/env.go | 46 ++------- internal/env/projcfg.go | 31 +----- internal/oauth/oauth.go | 72 +++---------- internal/output/account_view.go | 8 +- internal/output/file.go | 29 +----- internal/output/human.go | 108 +++++--------------- internal/output/jq.go | 5 +- internal/output/json.go | 65 ++++-------- internal/output/output.go | 9 +- internal/skill/skill.go | 46 ++++----- internal/ui/bar.go | 62 +++--------- internal/ui/brand.go | 37 ++----- internal/ui/brand_anim.go | 74 ++++---------- internal/ui/confirm.go | 2 +- internal/ui/prompt.go | 5 +- internal/ui/select.go | 4 +- internal/ui/spinner.go | 48 +++------ internal/ui/style.go | 10 +- internal/ui/theme.go | 5 +- internal/updater/updater.go | 151 +++++++--------------------- skills/embed.go | 2 + 41 files changed, 310 insertions(+), 1199 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bc76324..eb0fa67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,3 @@ - **Keep the CLI surface 1:1 with the [API endpoints](https://emailable.com/docs/api/).** Don't split one endpoint into multiple subcommands or merge two into one without a reason. -- **Comments are a last resort — write self-documenting code.** Never leave a - comment unless it's absolutely necessary. Name things well and structure code - so it explains itself; reach for a comment only when something is genuinely - confusing and the code truly can't convey it — a surprising trade-off, gotcha, - or spec quirk. Never restate what the code does, repeat a signature, or - narrate another package or command ("login writes here, logout clears there"); - those rot when the other code moves. diff --git a/cmd/account.go b/cmd/account.go index 9248849..c9f9607 100644 --- a/cmd/account.go +++ b/cmd/account.go @@ -5,7 +5,6 @@ import ( "github.com/spf13/cobra" ) -// newAccountCmd returns the `emailable account` command group. func newAccountCmd() *cobra.Command { account := &cobra.Command{ Use: "account", diff --git a/cmd/batch.go b/cmd/batch.go index 76c5d8b..cf90721 100644 --- a/cmd/batch.go +++ b/cmd/batch.go @@ -16,7 +16,6 @@ import ( "github.com/spf13/cobra" ) -// newBatchCmd returns the `emailable batch` command group. func newBatchCmd() *cobra.Command { batch := &cobra.Command{ Use: "batch", @@ -144,10 +143,7 @@ func newBatchCmd() *cobra.Command { } if wait { - // Surface the id before polling so a ctrl-c mid-wait still - // leaves it on screen for a later `batch get `. Suppressed - // in JSON mode (keeps scripted output a single object) and in - // quiet mode (the id is chrome there). + // Print before polling so ctrl-c mid-wait still leaves the id visible. if !jsonEff && !cctx.Quiet { printBatchID(cmd.ErrOrStderr(), submit.ID) } @@ -167,9 +163,6 @@ func newBatchCmd() *cobra.Command { return renderBatchOutcome(cmd, cctx, final, submit.ID, outPath, showAll) } - // In JSON mode, pass the submit response (id + message, plus any - // future fields) through unchanged rather than reshaping it to a - // bare {"id":...}; f.Print emits the captured raw body. return f.Print(submit) }, } @@ -178,7 +171,6 @@ func newBatchCmd() *cobra.Command { verify.Flags().StringP("output", "o", "", "Write results to FILE (.csv or .json; format inferred from extension)") verify.Flags().Bool("all", false, "Print the full results table inline instead of a summary") verify.Flags().Bool("stream", false, "Emit one JSON event per line while polling (implies --wait and --json)") - // Only flags the user explicitly set are forwarded, so server defaults stay in play. verify.Flags().String("url", "", "URL that will receive the batch results via HTTP POST") verify.Flags().Bool("retries", true, "Retry verifications when mail servers return certain responses, increasing accuracy") verify.Flags().StringSlice("response-fields", nil, "Fields to include in the response (default: all)") @@ -187,8 +179,6 @@ func newBatchCmd() *cobra.Command { return batch } -// submitBatchOptionsFromFlags builds SubmitBatchOptions, leaving fields unset -// for flags the user didn't touch so server defaults apply. func submitBatchOptionsFromFlags(cmd *cobra.Command) (*api.SubmitBatchOptions, error) { opts := &api.SubmitBatchOptions{} any := false @@ -222,9 +212,6 @@ func submitBatchOptionsFromFlags(cmd *cobra.Command) (*api.SubmitBatchOptions, e return opts, nil } -// printBatchID writes a one-time `Batch ID: ` line with a dimmed label. -// Printed before the bar/spinner starts so the id survives in scrollback and a -// ctrl-c mid-wait still leaves the user something to `batch get`. func printBatchID(w io.Writer, id string) { stf := output.StylerFor(w) label := stf(lipgloss.NewStyle().Foreground(lipgloss.Color("241"))).Render("Batch ID:") @@ -252,12 +239,10 @@ func (s *batchStreamer) emit(payload map[string]any) error { return err } -// emitSubmitted emits the initial `submitted` event for a newly created batch. func (s *batchStreamer) emitSubmitted(id string) error { return s.emit(map[string]any{"event": "submitted", "id": id}) } -// emitProgress emits a `progress` event; fired once per poll with a known total. func (s *batchStreamer) emitProgress(id string, processed, total int) error { return s.emit(map[string]any{ "event": "progress", @@ -267,14 +252,6 @@ func (s *batchStreamer) emitProgress(id string, processed, total int) error { }) } -// emitComplete emits the terminal `complete` event carrying the final status. -// -// When the status carries the verbatim API body, its fields (emails, -// total_counts, reason_counts, …) are merged through unchanged so per-row -// nulls and unmodeled fields survive — matching the passthrough guarantee of -// the non-streaming --json output. `event` and `id` are stamped on top. When -// no raw body is present (e.g. a hand-built status in a test), it falls back to -// reconstructing the event from the typed fields. func (s *batchStreamer) emitComplete(id string, status *api.BatchStatus) error { payload := map[string]any{ "event": "complete", @@ -285,8 +262,7 @@ func (s *batchStreamer) emitComplete(id string, status *api.BatchStatus) error { var fields map[string]json.RawMessage if err := json.Unmarshal(raw, &fields); err == nil { for k, v := range fields { - // event/id are CLI-owned envelope keys; never let the API - // body shadow them. + // Never let the API body shadow CLI-owned envelope keys. if k == "event" || k == "id" { continue } @@ -311,12 +287,6 @@ func (s *batchStreamer) emitComplete(id string, status *api.BatchStatus) error { return s.emit(payload) } -// applyStreamImplications turns on --wait and --json when --stream is set, both -// being preconditions for streaming (nothing to stream without polling; events -// are JSON). Rather than error on a missing flag, we just enable them. -// -// Returns the effective (waitOut, jsonOut) pair; the caller threads jsonOut -// through downstream output-format decisions. No package-level state is touched. func applyStreamImplications(stream, wait, jsonIn bool) (waitOut, jsonOut bool) { if !stream { return wait, jsonIn @@ -324,10 +294,7 @@ func applyStreamImplications(stream, wait, jsonIn bool) (waitOut, jsonOut bool) return true, true } -// Polling schedule for waitForCompletion. The first fastPollWindow of the -// wait uses fastPollInterval so small batches (which often finish in a few -// seconds) return promptly; after that we back off to slowPollInterval to -// avoid hammering the API on long-running batches. +// Fast-then-slow polling: short interval for the first fastPollWindow, then back off. const ( fastPollInterval = 1 * time.Second slowPollInterval = 5 * time.Second @@ -352,19 +319,15 @@ func waitForCompletion(ctx context.Context, client *api.Client, id string, jsonM ) start := time.Now() - // Queued-phase spinner: animates while we're polling with total=0 (server - // hasn't started processing yet). Stops as soon as a bar exists. queueSpinner := ui.NewTo(progressOut, "Queued") if uiEnabled { queueSpinner.Start() } for { - // Poll with partial=false intentionally. partial=true returns the - // "completed" shape (emails populated) the moment ANY result is ready, - // so treating it as done catches the batch mid-processing. partial=false - // stays in the "processing" shape until the whole batch finishes, and - // gives reliable processed/total counts for the progress bar. + // partial=false: stays in "processing" shape until the whole batch finishes, + // giving reliable counts. partial=true would signal done as soon as any result + // is ready, catching the batch mid-run. s, err := client.Batch(ctx, id, false) if err != nil { return nil, err @@ -372,13 +335,7 @@ func waitForCompletion(ctx context.Context, client *api.Client, id string, jsonM if uiEnabled && s.Total > 0 { if bar == nil || s.Total != lastTotal { - // First known total: kill the queued spinner before drawing - // the bar so the two don't fight for the line. queueSpinner.Stop() - - // Fixed 40-cell bar (auto-capped to terminal width). A - // constrained bar reads as a progress indicator rather than a - // full-width "wall" on short jobs. bar = ui.NewBar(progressOut, 40) bar.SetMessage(fmt.Sprintf("Verifying %d emails", s.Total)) bar.Start() @@ -387,20 +344,15 @@ func waitForCompletion(ctx context.Context, client *api.Client, id string, jsonM bar.Set(s.Processed, s.Total) } - // Stream-write errors are non-fatal; keep polling. if sw != nil && s.Total > 0 { _ = sw.emitProgress(id, s.Processed, s.Total) } if s.IsComplete() { - // Stop animations BEFORE returning so the final 100% frame - // is rendered and a newline written. queueSpinner.Stop() - // Counts-match completion (total>0 && processed>=total) can race - // with the API switching to the "completed" payload that carries - // the Emails slice. Retry briefly so callers always get the - // canonical completed shape instead of an empty Emails snapshot. + // Counts-match completion can race with the API switching to the + // "completed" payload; retry briefly to get the canonical shape with Emails. for i := 0; i < 3 && s.Total > 0 && len(s.Emails) == 0; i++ { select { case <-ctx.Done(): @@ -415,8 +367,6 @@ func waitForCompletion(ctx context.Context, client *api.Client, id string, jsonM } if bar != nil { - // The bar self-erases on Stop; the caller's summary line is - // the canonical "done" signal, so no need to retitle first. bar.Stop() } return s, nil @@ -434,9 +384,6 @@ func waitForCompletion(ctx context.Context, client *api.Client, id string, jsonM } } -// saveToFile writes v to path and prints a success line to stderr in non-JSON -// mode. The success line is suppressed in JSON mode (scripted output) and quiet -// mode. func saveToFile(cmd *cobra.Command, cctx *cmdCtx, v any, path string) error { n, err := output.WriteResults(v, output.SaveOptions{ Path: path, @@ -446,8 +393,6 @@ func saveToFile(cmd *cobra.Command, cctx *cmdCtx, v any, path string) error { return err } if !cctx.JSONMode { - // Success goes to stderr so stdout (the actual results file path - // or JSON payload) stays scriptable. h := &output.Human{W: cmd.ErrOrStderr(), Quiet: cctx.Quiet} msg := savedMessage(n, path) return h.Success(msg) @@ -455,9 +400,6 @@ func saveToFile(cmd *cobra.Command, cctx *cmdCtx, v any, path string) error { return nil } -// savedMessage returns the human "Saved …" line for a successful file -// write. Singular/plural for known row counts; falls back to a count-free -// "Saved to " when the count is unknown (e.g. account JSON dumps). func savedMessage(n int, path string) string { switch { case n <= 0: @@ -469,12 +411,6 @@ func savedMessage(n int, path string) string { } } -// renderBatchOutcome handles the final stdout/file rendering for a batch -// retrieval, dispatching on --output, --json, download/in-progress state, and -// whether per-email results are present. -// -// The per-email table is opt-in via --all regardless of batch size: UX feedback -// was that the table is rarely useful at a glance and the summary reads better. func renderBatchOutcome(cmd *cobra.Command, cctx *cmdCtx, status *api.BatchStatus, batchID, outPath string, showAll bool) error { if outPath != "" { return saveToFile(cmd, cctx, status, outPath) @@ -483,8 +419,6 @@ func renderBatchOutcome(cmd *cobra.Command, cctx *cmdCtx, status *api.BatchStatu return newOutput(cmd.OutOrStdout(), true).Print(status) } if status.DownloadFile != "" || len(status.Emails) == 0 { - // >1000-emails download case, or a still-processing batch with no - // per-email results. Defer to the formatter's dispatch. return newOutput(cmd.OutOrStdout(), false).Print(status) } diff --git a/cmd/context.go b/cmd/context.go index 3e9bab3..3695941 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -26,13 +26,6 @@ const apiKeyEnv = "EMAILABLE_API_KEY" const debugEnv = "EMAILABLE_DEBUG" -// cmdCtx is the shared bag of state every command needs: active environment, -// credentials (stored via the credentials package), config paths (managed by -// the config package), and persistent flags. -// -// Commands should prefer reading JSONMode/Quiet off the context rather than the -// package-level globals, so behavior stays consistent when a command-local -// helper overrides the effective value for its caller. type cmdCtx struct { Env *env.Environment CredentialsPath string @@ -44,16 +37,9 @@ type cmdCtx struct { JSONMode bool Quiet bool - // refreshNoticeWriter, when non-nil, receives a short stderr message the - // first time requireAuth performs an OAuth refresh during this command's - // lifetime. nil disables the notice (used in JSON mode). - refreshNoticeWriter io.Writer + refreshNoticeWriter io.Writer // non-nil enables a stderr notice on OAuth refresh; nil in JSON mode } -// newCmdCtxFor builds a cmdCtx and pre-wires the refresh-notice writer to the -// command's stderr. jsonMode is a parameter (rather than read off the global) -// so callers that compute an effective JSON value can pass it without mutating -// the global. func newCmdCtxFor(cmd *cobra.Command, jsonMode bool) (*cmdCtx, error) { c, err := newCmdCtx(jsonMode) if err != nil { @@ -62,9 +48,6 @@ func newCmdCtxFor(cmd *cobra.Command, jsonMode bool) (*cmdCtx, error) { return c.withRefreshNotice(cmd.ErrOrStderr()), nil } -// newCmdCtx resolves the active environment, locates the credentials and config -// paths, and loads the credentials. It does not enforce that the user is logged -// in — that's requireAuth's job. func newCmdCtx(jsonMode bool) (*cmdCtx, error) { e, err := env.Current() if err != nil { @@ -91,10 +74,6 @@ func newCmdCtx(jsonMode bool) (*cmdCtx, error) { }, nil } -// apiKeySource labels where the API key came from. There's no flag-source -// variant on purpose: --api-key only exists on `login`, where it triggers a -// save rather than a per-call override, so by the time any other command runs -// the key is either stored or unused. type apiKeySource string const ( @@ -105,8 +84,6 @@ const ( apiKeySourceMissing apiKeySource = "none" ) -// effectiveAPIKey returns the API key the CLI will use next and a label for its -// source. Resolution order: EMAILABLE_API_KEY env, then the stored API key. func (c *cmdCtx) effectiveAPIKey() (string, apiKeySource) { if v := os.Getenv(apiKeyEnv); v != "" { return v, apiKeySourceEnv @@ -117,15 +94,10 @@ func (c *cmdCtx) effectiveAPIKey() (string, apiKeySource) { return "", apiKeySourceNone } -// debugEnabled reports whether HTTP debug logging is on: --debug or a non-empty -// EMAILABLE_DEBUG. func debugEnabled() bool { return debugMode || os.Getenv(debugEnv) != "" } -// withRefreshNotice configures the context to emit a one-line stderr notice -// the first time it performs an OAuth refresh. Suppressed when JSONMode is -// true so machine-readable output stays clean. func (c *cmdCtx) withRefreshNotice(w io.Writer) *cmdCtx { if !c.JSONMode { c.refreshNoticeWriter = w @@ -161,26 +133,19 @@ func (c *cmdCtx) requireAuth(ctx context.Context) (*api.Client, error) { return api.NewWithOptions(c.Env.APIBaseURL, c.Credentials.AccessToken, c.clientOptions()), nil } -// clientOptions returns the api.Options used to build clients. func (c *cmdCtx) clientOptions() api.Options { return api.Options{Debug: debugEnabled()} } -// needsRefresh reports whether the stored access token is expired or close -// enough to expiry that we should refresh before the next request. Returns -// false when ExpiresAt is unset (older credentials without expiry tracking) -// so we don't refresh-loop on tokens that have no known TTL. func (c *cmdCtx) needsRefresh() bool { + // No ExpiresAt (older credentials without expiry tracking) means no known + // TTL — don't refresh-loop on it. if c.Credentials.RefreshToken == "" || c.Credentials.ExpiresAt.IsZero() { return false } return time.Now().Add(refreshSkew).After(c.Credentials.ExpiresAt) } -// refresh exchanges the stored refresh_token for a fresh access_token, -// updates c.Credentials in place, and persists to disk. When -// refreshNoticeWriter is non-nil, prints a short dimmed line so an attentive -// user sees that a refresh happened during their command. func (c *cmdCtx) refresh(ctx context.Context) error { oc := oauth.NewClient(c.Env.OAuthBaseURL, c.Env.ClientID, nil) tok, err := oc.Refresh(ctx, c.Credentials.RefreshToken) diff --git a/cmd/email_inputs.go b/cmd/email_inputs.go index f5762bd..2d3aaaf 100644 --- a/cmd/email_inputs.go +++ b/cmd/email_inputs.go @@ -10,43 +10,28 @@ import ( "strings" ) -// emailShape is a deliberately loose "anything@anything.anything" check. -// It's only used to distinguish literal-email args from misspelled file -// paths / garbage at the CLI boundary — full deliverability validation is -// the API's job. +// emailShape is a loose check used only to distinguish literal-email args from +// misspelled paths at the CLI boundary — real validation is the API's job. var emailShape = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) -// looksLikeEmail reports whether s plausibly resembles an email address. func looksLikeEmail(s string) bool { return emailShape.MatchString(strings.TrimSpace(s)) } -// stdinSource returns the reader stdin lines should be read from when `-` -// appears in the arg list, and whether stdin has data piped to it (i.e. is -// not a TTY). Overridable in tests. +// stdinSource is overridable in tests. var stdinSource = func() (io.Reader, bool) { fi, err := os.Stdin.Stat() if err != nil { return os.Stdin, false } - // A pipe or redirected file has ModeCharDevice unset. A TTY does not. piped := (fi.Mode() & os.ModeCharDevice) == 0 return os.Stdin, piped } -// collectEmails flattens a list of CLI inputs (literal emails or paths to -// .csv/.json/.txt files) into a deduped slice in first-seen order. Each -// non-path argument is treated as a single email — comma-separated lists -// are intentionally NOT split (commas in quoted local parts are technically -// valid per RFC 5321, and shell-natural CLI input is space-separated). For -// pasted comma-separated lists, save them to a .csv file and pass that. -// -// The special argument `-` reads newline-delimited emails from stdin -// (treated as plain-text format, like a .txt file). It may appear at most -// once and requires stdin to be piped — passing `-` from an interactive -// TTY is an error. -// -// Returns an error if no emails remain. +// collectEmails flattens CLI inputs (literal emails or .csv/.json/.txt paths) +// into a deduped slice. Comma-separated args are not split — shell input is +// space-separated, and commas in quoted local-parts are RFC 5321 valid. +// `-` reads newline-delimited emails from a piped stdin (once only). func collectEmails(inputs []string, field string) ([]string, error) { var out []string seen := make(map[string]struct{}) @@ -113,10 +98,6 @@ func collectEmails(inputs []string, field string) ([]string, error) { } continue } - // Not an existing file — must be a literal email. Reject anything - // that doesn't match the basic email shape so typos like a missing - // extension or a misspelled path don't get silently submitted to - // the API as an "email". if !looksLikeEmail(in) { if looksLikeBatchInput(in) { return nil, NewInvalidInputf("file not found: %s", in) @@ -132,9 +113,6 @@ func collectEmails(inputs []string, field string) ([]string, error) { return out, nil } -// isPath returns true if the input looks like a path to an existing file: -// has a recognised extension or contains a path separator, and the file -// exists on disk. func isPath(s string) bool { lower := strings.ToLower(s) hasExt := strings.HasSuffix(lower, ".csv") || @@ -151,9 +129,6 @@ func isPath(s string) bool { return !info.IsDir() } -// looksLikeBatchInput returns true if the argument passed to `verify` looks -// like a path that was intended for `batch verify`. Used to surface a -// migration hint after the split of the overloaded `verify` command. func looksLikeBatchInput(s string) bool { lower := strings.ToLower(s) if strings.HasSuffix(lower, ".csv") || @@ -244,7 +219,6 @@ func extractJSONEmails(top any, field, path string) ([]string, error) { if len(v) == 0 { return nil, nil } - // Array of strings? if _, ok := v[0].(string); ok { out := make([]string, 0, len(v)) for _, item := range v { @@ -254,7 +228,6 @@ func extractJSONEmails(top any, field, path string) ([]string, error) { } return out, nil } - // Array of objects → need field if _, ok := v[0].(map[string]any); ok { if field == "" { return nil, NewInvalidInputf("array of objects in %s; specify --field ", path) @@ -273,7 +246,6 @@ func extractJSONEmails(top any, field, path string) ([]string, error) { } return nil, NewInvalidInputf("unsupported json array element type in %s", path) case map[string]any: - // Top-level object: find array-valued field(s). if field != "" { arr, ok := v[field].([]any) if !ok { @@ -314,10 +286,6 @@ func readTXT(path string) ([]string, error) { return out, nil } -// readTXTReader reads newline-delimited emails from r using the same -// permissive rules as readTXT: lines are split on commas too, and the -// downstream collectEmails add() trims whitespace and skips empties. Used -// for both .txt files and stdin (`-`). func readTXTReader(r io.Reader) ([]string, error) { data, err := io.ReadAll(r) if err != nil { diff --git a/cmd/errors.go b/cmd/errors.go index 43d9a6b..f401503 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -13,36 +13,22 @@ import ( "github.com/spf13/cobra" ) -// errNotAuthenticated is returned by requireAuth when the active env has no -// stored access token. Rendered as a plain message — there's no API status -// code to attach. var errNotAuthenticated = errors.New("not logged in. Run `emailable login` first") -// invalidInputError carries a code=invalid_input classification through the -// error chain so renderError/exitCode can route local validation failures -// (bad email shape, missing file, malformed flag value, cobra positional-arg -// errors) to the documented invalid_input/exit-4 path. The wrapped message -// is rendered verbatim; no envelope is added. type invalidInputError struct{ msg string } func (e *invalidInputError) Error() string { return e.msg } -// NewInvalidInput returns an error tagged as code=invalid_input. The CLI's -// errorCode/exitCode plumbing maps this to "invalid_input" / exit 4 and the -// JSON renderer emits the standard flat `{"code":"invalid_input","message":...}` -// shape. +// NewInvalidInput returns an error that marks user input as invalid. func NewInvalidInput(msg string) error { return &invalidInputError{msg: msg} } -// NewInvalidInputf is the fmt.Errorf-style sibling of NewInvalidInput. +// NewInvalidInputf returns a formatted invalid-input error. func NewInvalidInputf(format string, args ...any) error { return &invalidInputError{msg: fmt.Sprintf(format, args...)} } -// wrapInvalidInputArgs adapts a cobra positional-args validator so its error -// (e.g. "accepts 1 arg(s), received 0") flows through the invalid_input -// classification instead of cobra's default exit-1 path. func wrapInvalidInputArgs(fn cobra.PositionalArgs) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if err := fn(cmd, args); err != nil { @@ -52,8 +38,7 @@ func wrapInvalidInputArgs(fn cobra.PositionalArgs) cobra.PositionalArgs { } } -// Exit codes. Documented in the README so scripts and AI agents can branch -// on the specific failure mode without parsing the error message. +// Exit codes — documented in the README; scripts can branch without parsing error messages. const ( exitOK = 0 exitGeneric = 1 @@ -63,9 +48,6 @@ const ( exitNetwork = 5 // network, server_error ) -// Stable error code values. Emitted as the top-level `code` field in JSON -// error output. The CLI prefers an API-provided `code` when present in the -// response body; otherwise these are derived from HTTP status / error type. const ( codeNotAuthenticated = "not_authenticated" codeForbidden = "forbidden" @@ -78,10 +60,6 @@ const ( codeUnknown = "unknown" ) -// errorCode returns the stable CLI error code for err. Pure function of the -// error value — does not consult the API body. Used both to populate the -// JSON output's `code` field (when the body didn't already carry one) and -// to drive exit-code classification. func errorCode(err error) string { if err == nil { return "" @@ -118,8 +96,6 @@ func errorCode(err error) string { return codeUnknown } -// exitCode maps an error to a documented process exit code. See the exit* -// constants for the taxonomy. func exitCode(err error) int { if err == nil { return exitOK @@ -138,10 +114,6 @@ func exitCode(err error) int { } } -// isNetworkError reports whether err is a network/transport failure rather -// than an HTTP response. Matches *url.Error (DNS/dial/timeout), net.Error, -// and *net.OpError. We deliberately don't match arbitrary errors — only -// connection-shaped ones — so misclassification stays narrow. func isNetworkError(err error) bool { var urlErr *url.Error if errors.As(err, &urlErr) { @@ -222,9 +194,6 @@ func renderJSONError(w io.Writer, err error) { var apiErr *api.Error if errors.As(err, &apiErr) { - // API body is a JSON object: pass it through, merging rate_limit - // and code as sibling top-level fields. The API's own `code` (if - // present) wins so callers see the canonical server-side value. if obj, ok := apiBodyAsObject(apiErr); ok { if apiErr.RateLimit != nil { obj["rate_limit"] = rateLimitMap(apiErr.RateLimit) @@ -235,8 +204,6 @@ func renderJSONError(w io.Writer, err error) { writeJSONLine(w, obj) return } - // Body wasn't a JSON object (HTML, empty, scalar, array): synthesize - // a flat object. msg := apiErr.Message if msg == "" { msg = fmt.Sprintf("HTTP %d", apiErr.StatusCode) @@ -252,17 +219,12 @@ func renderJSONError(w io.Writer, err error) { writeJSONLine(w, payload) return } - // Non-API errors (network, config, validation): flat object with `message` - // and `code`. No status_code because there isn't one. writeJSONLine(w, map[string]any{ "message": err.Error(), "code": code, }) } -// apiBodyAsObject parses the API error body as a JSON object. Returns the -// decoded map and true on success; false if the body is empty, not valid -// JSON, or valid JSON but not an object (string, number, array, null). func apiBodyAsObject(e *api.Error) (map[string]any, bool) { if len(e.Body) == 0 { return nil, false @@ -285,9 +247,6 @@ func rateLimitMap(rl *api.RateLimit) map[string]int { func writeJSONLine(w io.Writer, v any) { b, err := json.Marshal(v) if err != nil { - // Should never happen for the shapes we build, but fall back to the - // same flat shape used by every other error path so consumers see a - // consistent schema instead of an unexpected envelope. fmt.Fprintf(w, `{"code":%q,"message":%q}`+"\n", codeUnknown, err.Error()) return } diff --git a/cmd/login.go b/cmd/login.go index b8627fd..7aa70d1 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -19,14 +19,6 @@ import ( "golang.org/x/term" ) -// newLoginCmd returns the `emailable login` cobra command. The actual flow -// (OAuth device authorization, or API-key save) lives in runLoginE. -// -// `--api-key` is intentionally a local flag here rather than a persistent -// root flag — credentials don't belong on argv for every command, where -// they'd land in shell history and `ps` output. Login is the one place a -// user has explicitly opted into committing the key, so the flag lives -// here and nowhere else. func newLoginCmd() *cobra.Command { cmd := &cobra.Command{ Use: "login", @@ -47,28 +39,14 @@ func newLoginCmd() *cobra.Command { return cmd } -// runLoginE drives one of two flows depending on how it's invoked: -// -// - API key (non-interactive): if `--api-key VALUE` is set, or stdin is -// piped (e.g. `op read ... | emailable login`), the key is persisted -// to the credentials file and validated against /v1/account to fetch -// the owner email. No OAuth round-trip happens. -// - OAuth device flow (interactive): the default — request a device -// code, open the browser to authorize, poll for the access_token, -// persist credentials, fetch the owner email. -// -// oauth.ErrAccessDenied and oauth.ErrExpiredToken collapse to friendly -// messages; other errors propagate verbatim so the user sees the cause. func runLoginE(cmd *cobra.Command, _ []string) error { ctx, err := newCmdCtx(jsonOutput) if err != nil { return err } - // API-key path takes precedence over OAuth. We deliberately do NOT - // consult EMAILABLE_API_KEY here — that env var is for per-invocation - // use; logging in is an explicit persistence action and should require - // the user to commit to it via flag or stdin pipe. + // EMAILABLE_API_KEY is not consulted here — it's for per-invocation use; + // login is an explicit persistence action requiring flag or stdin pipe. if key, ok := apiKeyForLogin(); ok { return loginWithAPIKey(cmd, ctx, key) } @@ -86,11 +64,8 @@ func runLoginE(cmd *cobra.Command, _ []string) error { if openURL == "" { openURL = dc.VerificationURI } - // verification_uri_complete embeds the code, so we open the browser - // straightaway rather than gating on a keypress. We always print the code - // and URL too — never just on failure: over SSH or in a container the open - // may "succeed" on a browser the user can't see, and the URL is their only - // way through. The code lets them confirm the page matches what we sent. + // Always print code+URL even on success: over SSH the browser may open + // somewhere the user can't see, and the code confirms the page matches. if err := openBrowser(openURL); err != nil { _ = hStderr.Notice("Couldn't open your browser automatically.") } else { @@ -124,19 +99,14 @@ func runLoginE(cmd *cobra.Command, _ []string) error { return err } - // Best-effort fetch of the owner email so the success line is - // personalized. A failure here doesn't undo the login — the token is - // already on disk above — we just fall back to a generic message. + // Best-effort: token is already on disk, so an account fetch failure only + // degrades the success message, it doesn't undo the login. apiClient := api.New(ctx.Env.APIBaseURL, creds.AccessToken, nil) acc, accErr := apiClient.Account(cmd.Context()) h := &output.Human{W: cmd.OutOrStdout(), Quiet: ctx.Quiet} if accErr == nil && acc != nil { creds.OwnerEmail = acc.OwnerEmail if saveErr := creds.Save(ctx.CredentialsPath); saveErr != nil { - // Login succeeded; the second save (adding owner_email) is a - // best-effort enhancement. Surface a dimmed note so the user - // knows their credentials file is slightly incomplete without - // aborting the login flow. noticeW := &output.Human{W: cmd.ErrOrStderr(), Quiet: ctx.Quiet} _ = noticeW.Notice(fmt.Sprintf("Couldn't update owner_email in credentials: %v", saveErr)) } @@ -145,16 +115,6 @@ func runLoginE(cmd *cobra.Command, _ []string) error { return h.Success("Logged in.") } -// apiKeyForLogin returns the API key the user supplied for this login -// invocation and a bool indicating whether one was provided. Two sources, -// in order: -// -// 1. The persistent --api-key flag (e.g. `emailable login --api-key XXX`). -// Convenient but the key lands in shell history. -// 2. Piped stdin (e.g. `op read ... | emailable login`). Preferred for -// secrets because nothing about the key is recorded by the shell. -// -// EMAILABLE_API_KEY is intentionally NOT consulted here — see runLoginE. func apiKeyForLogin() (string, bool) { if apiKey != "" { return strings.TrimSpace(apiKey), true @@ -172,14 +132,8 @@ func apiKeyForLogin() (string, bool) { return "", false } -// loginWithAPIKey persists key to the credentials file, then calls -// /v1/account to validate it and grab the owner email. A bad key surfaces -// as whatever /v1/account returns (typically a 401 with -// code=not_authenticated) — we don't write the key to disk until we know -// it works. func loginWithAPIKey(cmd *cobra.Command, ctx *cmdCtx, key string) error { - // Validate first against /v1/account so a typo doesn't silently leave - // a broken key on disk. + // Validate before writing to disk so a typo doesn't silently leave a broken key. apiClient := api.NewWithOptions(ctx.Env.APIBaseURL, key, api.Options{Debug: debugEnabled()}) acc, err := apiClient.Account(cmd.Context()) if err != nil { @@ -188,9 +142,8 @@ func loginWithAPIKey(cmd *cobra.Command, ctx *cmdCtx, key string) error { creds := ctx.Credentials creds.APIKey = key - // Saving a fresh API key supersedes any prior OAuth credentials; clear - // them so the auth source is unambiguous and so a later `logout` - // doesn't try to revoke a token the user has already abandoned. + // Clear OAuth fields so auth source is unambiguous and logout won't try + // to revoke an abandoned token. creds.AccessToken = "" creds.RefreshToken = "" creds.ExpiresAt = time.Time{} @@ -208,9 +161,6 @@ func loginWithAPIKey(cmd *cobra.Command, ctx *cmdCtx, key string) error { return h.Success("Logged in with API key.") } -// openBrowser launches the OS's default browser pointing at url. Returns an -// error when the platform isn't supported or the launch command fails to -// start. The child process is detached — we don't wait on it. func openBrowser(url string) error { var c *exec.Cmd switch runtime.GOOS { diff --git a/cmd/logout.go b/cmd/logout.go index e602756..8842837 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -7,8 +7,6 @@ import ( "github.com/spf13/cobra" ) -// newLogoutCmd returns the `emailable logout` cobra command. The actual -// flow (revoke + clear stored credentials) lives in runLogoutE. func newLogoutCmd() *cobra.Command { return &cobra.Command{ Use: "logout", @@ -21,12 +19,6 @@ func newLogoutCmd() *cobra.Command { } } -// runLogoutE clears any stored credentials — OAuth access token AND/OR -// stored API key. For OAuth, best-effort revokes the token at the server -// first (errors ignored — server may be down or the token already -// invalid). API keys aren't revocable client-side, so the cleanup is just -// deleting the credentials file. Idempotent: running logout when not logged -// in still succeeds. func runLogoutE(cmd *cobra.Command, _ []string) error { ctx, err := newCmdCtx(jsonOutput) if err != nil { diff --git a/cmd/man.go b/cmd/man.go index 907fc25..5750599 100644 --- a/cmd/man.go +++ b/cmd/man.go @@ -10,11 +10,6 @@ import ( "github.com/spf13/cobra/doc" ) -// newManCmd returns the hidden `emailable man` command. It generates a -// man(1) page for the root command and one for each subcommand into the -// target directory, suitable for installation under /usr/local/share/man -// or for bundling in a release tarball. Hidden from --help because it's a -// release/packaging tool rather than something a user runs interactively. func newManCmd() *cobra.Command { cmd := &cobra.Command{ Use: "man --output DIR", @@ -33,7 +28,6 @@ func newManCmd() *cobra.Command { if err := os.MkdirAll(abs, 0o755); err != nil { return fmt.Errorf("create output dir: %w", err) } - // Walk from the root command so every subcommand gets a page. header := &doc.GenManHeader{ Title: "EMAILABLE", Section: "1", diff --git a/cmd/root.go b/cmd/root.go index 43f2f68..350905b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,7 +27,6 @@ var ( buildDate = "" ) -// releaseURLPrefix is the GitHub releases URL we link to from --version output. const releaseURLPrefix = "https://github.com/emailable/emailable-cli/releases/tag/" // Only a clean semver release has a GitHub tag to link to; snapshot and @@ -38,7 +37,6 @@ var releaseVersion = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) // (rather than re-querying the cobra flag set) to pick an output formatter. var jsonOutput bool -// jqExpr backs the --jq flag; jqQuery is its compiled form. --jq implies --json. var ( jqExpr string jqQuery *output.Query @@ -81,12 +79,7 @@ const ( groupHelpers = "helpers" // built-in cobra commands (help, completion) ) -// versionDisplay returns the multi-line version blurb used by both `--version` -// and the `version` subcommand. func versionDisplay() string { - // Use the resolved version (collectVersionInfo falls back to the Go - // toolchain's module version for `go install` builds where ldflags - // weren't injected). v := collectVersionInfo().Version var b strings.Builder @@ -114,10 +107,6 @@ func versionDisplay() string { return b.String() } -// versionInfo holds the structured pieces that make up the version blurb. -// Used by versionExtras (for the human string) and by `version --json` to -// emit a machine-readable representation. Fields are zero-valued when the -// corresponding data isn't available (e.g. no VCS info in a stripped build). type versionInfo struct { Version string BuildDate string // either ldflags-injected buildDate or vcs.time (YYYY-MM-DD) @@ -125,7 +114,6 @@ type versionInfo struct { Dirty bool // vcs.modified — only meaningful when Commit is set } -// collectVersionInfo gathers version metadata from ldflags and VCS build info. func collectVersionInfo() versionInfo { vi := versionInfo{Version: version, BuildDate: buildDate, Commit: commit} info, ok := debug.ReadBuildInfo() @@ -169,9 +157,6 @@ func collectVersionInfo() versionInfo { return vi } -// versionExtras returns the date / commit info shown in parens after the -// version number. Prefers ldflags-injected values (GoReleaser); falls back to -// runtime/debug.ReadBuildInfo VCS data for local checkouts. func versionExtras() string { vi := collectVersionInfo() var parts []string @@ -188,13 +173,10 @@ func versionExtras() string { return strings.Join(parts, ", ") } -// longDescription is the root help blurb. Intentionally short and feature- -// agnostic so it doesn't go stale as the product grows. const longDescription = "Command-line interface for Emailable's email verification API." -// newRootCmd returns a fresh root cobra.Command. Used by Execute and by tests. -// The v argument lets tests inject a deterministic version string; production -// callers pass the package-level `version` (set via ldflags). +// newRootCmd returns a fresh root cobra.Command. The v argument lets tests +// inject a deterministic version string; production callers pass `version`. func newRootCmd(v string) *cobra.Command { resetRootFlagState() @@ -211,11 +193,8 @@ func newRootCmd(v string) *cobra.Command { Version: versionDisplay(), SilenceUsage: true, SilenceErrors: true, - // A bare `emailable` either onboards a logged-out user on a TTY or - // prints help; see runRootDefault. - RunE: runRootDefault, - // Resolve the default output format before any RunE runs. Precedence: - // --json flag > EMAILABLE_OUTPUT > config `output` > "human". + RunE: runRootDefault, + // Precedence: --json flag > EMAILABLE_OUTPUT > config `output` > "human". PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { if !cmd.Flags().Changed("json") { merged, err := env.MergedConfig() @@ -226,12 +205,10 @@ func newRootCmd(v string) *cobra.Command { jsonOutput = true } } - // Clear first so a query from an earlier in-process run can't leak - // into a command invoked without --jq. + // Clear first so a query from an earlier in-process run can't leak. jqQuery = nil if jqExpr != "" { - // Set JSON mode before compiling so a bad-expression error - // still renders as JSON, honoring --jq's implied --json. + // Set JSON mode before compiling so a bad-expression error renders as JSON. jsonOutput = true q, err := output.CompileQuery(jqExpr) if err != nil { @@ -242,7 +219,6 @@ func newRootCmd(v string) *cobra.Command { return nil }, } - // Print just the blurb; versionDisplay already includes "emailable version ". root.SetVersionTemplate("{{ .Version }}\n") root.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Return JSON response") @@ -250,8 +226,6 @@ func newRootCmd(v string) *cobra.Command { root.PersistentFlags().BoolVar(&debugMode, "debug", false, "Dump HTTP requests/responses to stderr (also EMAILABLE_DEBUG)") root.PersistentFlags().BoolVarP(&quietMode, "quiet", "q", false, "Suppress non-error human output (success lines, hints, progress)") - // Register groups so cobra knows about them; the custom usage func is - // what actually renders them under gh-style headings. root.AddGroup( &cobra.Group{ID: groupCore, Title: "CORE COMMANDS"}, &cobra.Group{ID: groupAuth, Title: "AUTHENTICATION COMMANDS"}, @@ -280,12 +254,8 @@ func newRootCmd(v string) *cobra.Command { root.AddCommand(verify, batch, account, login, logout, status, versionSub, skillSub, newManCmd()) - // Hide cobra's auto-generated `completion` command from --help. It's - // still callable, just doesn't clutter the curated command list. root.CompletionOptions.HiddenDefaultCmd = true - // Route both usage errors and --help/`help` through the same renderer so - // they produce identical output. root.SetUsageFunc(func(c *cobra.Command) error { return renderHelp(c, c.OutOrStderr()) }) @@ -306,29 +276,21 @@ func resetRootFlagState() { } // Execute runs the root command. -// -// Cobra's default error rendering is silenced so every RunE error is routed -// through renderError (stderr only). Exit code is non-zero on any error. func Execute() { root := newRootCmd(version) - // Skip the network call entirely for opt-outs knowable before flags parse. - // Uses IsTerminal, not IsTTY, so NO_COLOR (a styling pref) still gets checks. - // JSONMode/Quiet are flag-derived and gate only the notice, below. + // Uses IsTerminal (not IsTTY) so NO_COLOR doesn't suppress update checks. preSkip := updater.ShouldSkip(updater.Conditions{ CurrentVersion: version, StderrTTY: ui.IsTerminal(root.ErrOrStderr()), OptOut: env.UpdateNotifierOptOut(), }) - // Kick off the update check in parallel with command execution; resultCh - // carries the outcome. The post-command grace block tolerates a hung check. updCtx, updCancel := context.WithCancel(context.Background()) defer updCancel() resultCh := make(chan updater.Result, 1) if preSkip == updater.SkipNone { - // Run even when we may end up skipping the notice (e.g. JSON mode) so - // the cache still refreshes; the gate below decides on printing. + // Run even in JSON mode so the cache refreshes; the gate below decides on printing. go func() { resultCh <- updater.Check(updCtx, version, updater.CacheDir()) }() @@ -336,8 +298,6 @@ func Execute() { runErr := root.Execute() - // Re-check with the now-parsed flags to gate the notice. If the pre-flight - // already opted out, no goroutine ran, so keep that reason. skip := preSkip if skip == updater.SkipNone { skip = updater.ShouldSkip(updater.Conditions{ @@ -351,8 +311,6 @@ func Execute() { if runErr != nil { renderError(root.ErrOrStderr(), runErr, jsonOutput) - // Best-effort notice on error path too, but only if the gate - // allows. Print BEFORE exiting; cap the wait at 1s. if skip == updater.SkipNone { waitAndNotify(root.ErrOrStderr(), resultCh, updCancel, updateNoticeWait) } @@ -360,23 +318,14 @@ func Execute() { } if skip != updater.SkipNone { - // Nothing to print; the background goroutine may still be running - // (e.g. mid-fetch). It'll be torn down by updCancel via the - // deferred cancel above, so we exit cleanly. return } waitAndNotify(root.ErrOrStderr(), resultCh, updCancel, updateNoticeWait) } -// updateNoticeWait is the hard ceiling on how long Execute will block at -// shutdown waiting for the update goroutine to deliver a result. 1s matches -// the spec ("up to 1 second extra"). If the check is still running when -// this elapses, we abandon and exit — never delaying the user. +// updateNoticeWait caps how long Execute blocks for the update check. 1s matches the spec. const updateNoticeWait = 1 * time.Second -// waitAndNotify blocks for at most wait, then prints the update notice (if the -// check finished and produced one) or returns silently. updCancel lets a -// still-running fetch shut itself down promptly. func waitAndNotify(w io.Writer, resultCh <-chan updater.Result, updCancel context.CancelFunc, wait time.Duration) { timer := time.NewTimer(wait) defer timer.Stop() @@ -384,15 +333,10 @@ func waitAndNotify(w io.Writer, resultCh <-chan updater.Result, updCancel contex case r := <-resultCh: _ = updater.MaybeNotify(w, r, ui.IsTTY(w)) case <-timer.C: - // Still pending; abandon. Cancel the in-flight HTTP so the - // goroutine doesn't leak past process exit. updCancel() } } -// renderHelp writes a gh-style help screen for c to w. TTY detection is -// performed once against w so ANSI escape codes are suppressed when output -// is piped, redirected, or captured by tests via a bytes.Buffer. func renderHelp(c *cobra.Command, w io.Writer) error { tty := ui.IsTTY(w) var b strings.Builder @@ -406,7 +350,6 @@ func renderHelp(c *cobra.Command, w io.Writer) error { b.WriteString("\n\n") } - // USAGE b.WriteString(ui.Heading("USAGE", tty)) b.WriteByte('\n') b.WriteString(" ") @@ -416,16 +359,12 @@ func renderHelp(c *cobra.Command, w io.Writer) error { } b.WriteString("\n\n") - // Command groups (only at levels with subcommands). if c.HasAvailableSubCommands() { writeGroupedCommands(&b, c, tty) } - // Local flags only by default; persistent flags from root would repeat on - // every subcommand. On root, LocalFlags already includes the persistent ones. flags := c.LocalFlags() if c.HasAvailableInheritedFlags() { - // Merge inherited persistent flags so subcommand help still shows them. flags.AddFlagSet(c.InheritedFlags()) } if flags.HasAvailableFlags() { @@ -435,9 +374,6 @@ func renderHelp(c *cobra.Command, w io.Writer) error { b.WriteByte('\n') } - // EXAMPLES + LEARN MORE only on the root command — gh follows the same - // pattern. Subcommand-specific examples live in each command's Example - // field if/when they're added. if !c.HasParent() { b.WriteString(ui.Heading("EXAMPLES", tty)) b.WriteByte('\n') @@ -469,18 +405,12 @@ func renderHelp(c *cobra.Command, w io.Writer) error { return err } -// writeGroupedCommands renders the subcommand listing, grouped by GroupID and -// in the same order groups were registered on the root. Commands without a -// group (or whose group isn't registered on the parent) fall into a generic -// "COMMANDS" section so nothing is dropped. func writeGroupedCommands(b *strings.Builder, c *cobra.Command, tty bool) { type bucket struct { title string cmds []*cobra.Command } - // Preserve registered group order. For non-root commands (e.g. `batch`) - // there are no groups; everything falls through to the default bucket. order := make([]string, 0, len(c.Groups())) buckets := make(map[string]*bucket, len(c.Groups())) for _, g := range c.Groups() { @@ -500,7 +430,6 @@ func writeGroupedCommands(b *strings.Builder, c *cobra.Command, tty bool) { } } - // Find the widest name across all buckets for nice alignment. pad := 0 for _, id := range order { for _, sub := range buckets[id].cmds { @@ -541,7 +470,6 @@ func writeGroupedCommands(b *strings.Builder, c *cobra.Command, tty bool) { writeBucket(def) } -// indent prefixes every line of s with prefix. Trailing newline is preserved. func indent(s, prefix string) string { if s == "" { return s diff --git a/cmd/skill.go b/cmd/skill.go index 3181c09..811c07d 100644 --- a/cmd/skill.go +++ b/cmd/skill.go @@ -15,8 +15,6 @@ import ( "github.com/spf13/cobra" ) -// newSkillCmd is the `emailable skill` command group. Bare invocation -// on a TTY launches a picker; otherwise it shows help. func newSkillCmd() *cobra.Command { cmd := &cobra.Command{ Use: "skill", diff --git a/cmd/status.go b/cmd/status.go index 9bc6522..240e2f1 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -10,8 +10,6 @@ import ( "github.com/spf13/cobra" ) -// newStatusCmd returns the `emailable status` command: local auth state, no -// network call. func newStatusCmd() *cobra.Command { return &cobra.Command{ Use: "status", @@ -27,9 +25,6 @@ func newStatusCmd() *cobra.Command { } } -// runStatusE prints the active environment, file paths, and stored credential -// state. Never hits the network. Human mode renders a labeled block; --json -// emits a flat object. func runStatusE(cmd *cobra.Command, _ []string) error { cctx, err := newCmdCtx(jsonOutput) if err != nil { @@ -72,9 +67,6 @@ func runStatusE(cmd *cobra.Command, _ []string) error { return printStatusHuman(cmd, cctx, source, loggedIn, expiresAt, expiresIn) } -// authSourceFor returns the credential source the CLI would use for the next -// request, distinguishing the API-key locations (env, stored). Returns "oauth" -// for a stored OAuth token and "none" when no credentials are configured. func authSourceFor(cctx *cmdCtx) (source string, loggedIn bool) { if _, src := cctx.effectiveAPIKey(); src != apiKeySourceNone { return string(src), true @@ -85,7 +77,6 @@ func authSourceFor(cctx *cmdCtx) (source string, loggedIn bool) { return string(apiKeySourceMissing), false } -// printStatusHuman renders the status block for human consumption. func printStatusHuman(cmd *cobra.Command, cctx *cmdCtx, source string, loggedIn bool, expiresAt string, expiresIn int) error { w := cmd.OutOrStdout() stf := output.StylerFor(w) @@ -147,9 +138,6 @@ func printStatusHuman(cmd *cobra.Command, cctx *cmdCtx, source string, loggedIn return nil } -// humanizeSeconds renders a duration as "Nd", "Nh", "Nm", or "Ns" using the -// largest unit that yields a non-zero integer. Used in status for the -// "expires in" hint; full precision lives in the expires_at timestamp. func humanizeSeconds(s int) string { switch { case s >= 86400: diff --git a/cmd/verify.go b/cmd/verify.go index 2490267..1a0e064 100644 --- a/cmd/verify.go +++ b/cmd/verify.go @@ -6,17 +6,6 @@ import ( "github.com/spf13/cobra" ) -// newVerifyCmd returns the `emailable verify` cobra command. -// -// `verify` is single-email real-time verification. For multiple emails or -// file input (CSV / JSON / TXT), users should reach for -// `emailable batch verify` instead — the two commands were split apart so -// each could have a focused flag surface and clearer help text. -// -// Flags map 1:1 to the GET /v1/verify query parameters documented at -// https://emailable.com/docs/api/emails/. Each is only forwarded when the -// user explicitly set it (cobra's pflag.Changed), so omitted flags fall -// through to whatever server-side default is current. func newVerifyCmd() *cobra.Command { cmd := &cobra.Command{ Use: "verify EMAIL", @@ -54,11 +43,6 @@ func newVerifyCmd() *cobra.Command { f := newOutput(cmd.OutOrStdout(), jsonOutput) - // Real-time verification can take several seconds (SMTP probes, - // Accept-All checks). Run a spinner so the user sees motion - // instead of a frozen prompt. TTY-gated; suppressed in JSON - // mode so scripted output stays clean, and in quiet mode since - // the spinner counts as non-error human chrome. var sp *ui.Spinner if !jsonOutput && !ctx.Quiet { sp = ui.NewTo(cmd.ErrOrStderr(), "Verifying "+email) @@ -80,10 +64,8 @@ func newVerifyCmd() *cobra.Command { return cmd } -// verifyOptionsFromFlags assembles api.VerifyOptions from the cobra flag -// set. Flags that the user didn't explicitly set are left nil/zero so the -// server's default applies — the CLI never silently overrides a default -// just because cobra has a fallback value for the flag type. +// verifyOptionsFromFlags only populates opts for flags the user explicitly set, +// so omitted flags fall through to server defaults rather than cobra's zero values. func verifyOptionsFromFlags(cmd *cobra.Command) (*api.VerifyOptions, error) { opts := &api.VerifyOptions{} any := false diff --git a/cmd/version.go b/cmd/version.go index 77f1870..486bd0f 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -7,9 +7,6 @@ import ( "github.com/spf13/cobra" ) -// newVersionCmd returns the `emailable version` subcommand. By default it -// prints the same multi-line human blurb as `emailable --version`; with --json -// it emits a machine-readable object instead. func newVersionCmd() *cobra.Command { return &cobra.Command{ Use: "version", @@ -26,9 +23,6 @@ func newVersionCmd() *cobra.Command { } } -// writeVersionJSON emits the structured version payload to the command's -// stdout. Fields are omitted when empty so callers can distinguish "unknown" -// from a zero value (e.g. no VCS data => no "commit" key at all). func writeVersionJSON(cmd *cobra.Command) error { vi := collectVersionInfo() @@ -40,8 +34,7 @@ func writeVersionJSON(cmd *cobra.Command) error { } if vi.Commit != "" { payload["commit"] = vi.Commit - // dirty is only meaningful alongside a commit; without VCS info we - // can't honestly say whether the tree was modified. + // dirty is only meaningful alongside a commit; omit both when there's no VCS info. payload["dirty"] = vi.Dirty } if e, err := env.Current(); err == nil && e.Name != "default" { diff --git a/cmd/welcome.go b/cmd/welcome.go index e761c92..8c64ce0 100644 --- a/cmd/welcome.go +++ b/cmd/welcome.go @@ -15,11 +15,8 @@ import ( "golang.org/x/term" ) -// runRootDefault handles a bare `emailable` (no subcommand): the getting-started -// flow on an interactive first run, otherwise help. func runRootDefault(cmd *cobra.Command, args []string) error { - // A positional that wasn't a known subcommand lands here; show help so the - // user sees the command list rather than the welcome flow. + // Unknown subcommand lands here; show help rather than the welcome flow. if len(args) > 0 { return cmd.Help() } @@ -34,11 +31,8 @@ func runRootDefault(cmd *cobra.Command, args []string) error { return cmd.Help() } -// isFirstRun reports whether a bare invocation should launch the getting- -// started flow: an interactive terminal (both ends), not already -// authenticated, and not in a machine-output / quiet mode. NO_COLOR is -// deliberately not consulted here — those users still get onboarded, just with -// a static, uncolored mark. +// isFirstRun reports whether a bare invocation should launch onboarding. +// NO_COLOR is not consulted — those users still get onboarded, just uncolored. func isFirstRun(cctx *cmdCtx, cmd *cobra.Command) bool { if jsonOutput || quietMode { return false @@ -49,8 +43,7 @@ func isFirstRun(cctx *cmdCtx, cmd *cobra.Command) bool { return terminalsInteractive(cmd) } -// terminalsInteractive reports whether the command's stdout and the process's -// stdin are both terminals. A package var so tests can stub it without a PTY. +// terminalsInteractive is a var so tests can stub it without a PTY. var terminalsInteractive = func(cmd *cobra.Command) bool { out, ok := cmd.OutOrStdout().(*os.File) if !ok || !term.IsTerminal(int(out.Fd())) { @@ -59,8 +52,6 @@ var terminalsInteractive = func(cmd *cobra.Command) bool { return term.IsTerminal(int(os.Stdin.Fd())) } -// runGettingStarted is the first-run onboarding shown for a bare, logged-out -// invocation on a terminal. func runGettingStarted(cmd *cobra.Command) error { out := cmd.OutOrStdout() @@ -68,8 +59,6 @@ func runGettingStarted(cmd *cobra.Command) error { stf := output.StylerFor(out) title := stf(lipgloss.NewStyle().Bold(true).Foreground(ui.BrandPurple)) - // Body copy uses the terminal's default foreground (white on dark themes) - // rather than a dim gray, so the welcome reads as primary text. body := stf(lipgloss.NewStyle()) fmt.Fprintln(out) @@ -83,8 +72,6 @@ func runGettingStarted(cmd *cobra.Command) error { if err != nil { return err } - // Don't dangle NEXT STEPS in front of someone who bailed out (esc) without - // logging in — they have no credentials to run those commands with. if !loggedIn { return nil } @@ -96,7 +83,6 @@ func runGettingStarted(cmd *cobra.Command) error { return printNextSteps(out) } -// loginMethod is the user's choice on the getting-started auth menu. type loginMethod int const ( @@ -105,14 +91,6 @@ const ( loginMethodCanceled ) -// runOnboardingLogin presents the two ways to authenticate — browser sign-in or -// an API key — as an arrow-key menu, then runs the chosen flow. It reports -// whether the user actually logged in, so the caller can skip the post-login -// NEXT STEPS when they canceled out (esc). Without an interactive terminal there -// are no keystrokes to read, so it skips the menu and falls back to the OAuth -// device flow. The gate is terminal-ness, not ui.IsTTY — a NO_COLOR user on a -// real terminal still gets the menu (rendered uncolored), like the rest of -// onboarding. func runOnboardingLogin(cmd *cobra.Command, out io.Writer) (loggedIn bool, err error) { if !terminalsInteractive(cmd) { return true, runLoginE(cmd, nil) @@ -132,8 +110,6 @@ func runOnboardingLogin(cmd *cobra.Command, out io.Writer) (loggedIn bool, err e } } -// chooseLoginMethod renders the sign-in/API-key menu and maps the selection to -// a loginMethod. func chooseLoginMethod(out io.Writer) (loginMethod, error) { choices := []ui.Choice{ {Label: "Sign in to your account", Hint: "Opens your browser to authorize"}, @@ -152,13 +128,6 @@ func chooseLoginMethod(out io.Writer) (loginMethod, error) { return loginMethodOAuth, nil } -// loginWithPromptedAPIKey reads an API key from the terminal — masked as -// bullets so a paste still shows visible feedback — then validates and persists -// it via the shared login path. A rejected or empty key re-prompts in place -// rather than dropping the user back to the shell, so a fat-fingered paste is -// recoverable. A canceled prompt (esc / ctrl-c) aborts onboarding quietly. -// Failures that aren't about the key itself (network, server errors) propagate -// so the user isn't trapped looping on an outage. func loginWithPromptedAPIKey(cmd *cobra.Command, out io.Writer) (loggedIn bool, err error) { ctx, err := newCmdCtx(jsonOutput) if err != nil { @@ -183,9 +152,8 @@ func loginWithPromptedAPIKey(cmd *cobra.Command, out io.Writer) (loggedIn bool, if err == nil { return true, nil } - // An auth-rejection status means the key was refused — recoverable, so - // re-prompt. Everything else propagates: notably 402 (out of credits) - // means the key is valid, and re-typing it would just loop. + // Re-prompt only for auth rejections; 402 (out of credits) means the key + // is valid, so looping would not help. var apiErr *api.Error if errors.As(err, &apiErr) && keyRejected(apiErr.StatusCode) { msg := "That API key wasn't accepted." @@ -199,10 +167,6 @@ func loginWithPromptedAPIKey(cmd *cobra.Command, out io.Writer) (loggedIn bool, } } -// keyRejected reports whether an API status means the key itself was refused, -// so re-prompting for a different key can help. Other statuses — 402 -// out-of-credits, 404, 429, 5xx — aren't fixed by a new key and propagate -// instead. func keyRejected(status int) bool { switch status { case 400, 401, 403: @@ -212,7 +176,6 @@ func keyRejected(status int) bool { } } -// maybeOfferSkillInstall is the post-login Yes/No prompt. No-op off TTY. func maybeOfferSkillInstall(cmd *cobra.Command, out io.Writer) error { if !terminalsInteractive(cmd) { return nil @@ -252,7 +215,6 @@ func maybeOfferSkillInstall(cmd *cobra.Command, out io.Writer) error { return nil } -// printNextSteps prints a few starter commands to try after onboarding. func printNextSteps(w io.Writer) error { tty := ui.IsTTY(w) examples := [][2]string{ diff --git a/internal/api/client.go b/internal/api/client.go index 1102d44..3040aa2 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -1,5 +1,4 @@ -// Package api is the HTTP client for the Emailable v1 REST API. All requests -// carry `Authorization: Bearer `. +// Package api is the HTTP client for the Emailable v1 REST API. package api import ( @@ -17,9 +16,7 @@ import ( "time" ) -// defaultRequestTimeout caps the total wall time for a single API call. -// Generous because a real-time verify can spend ~30s SMTP-probing slow MX -// hosts, but bounded so a hung connection can't wedge the CLI forever. +// defaultRequestTimeout is generous because real-time verify can spend ~30s SMTP-probing slow MX hosts. const defaultRequestTimeout = 60 * time.Second // Retry knobs for transient API responses. maxRetrySleep caps the per-attempt @@ -33,17 +30,10 @@ const ( // Options tunes a Client. All fields are optional. type Options struct { - // HTTPClient is the underlying transport. nil => a private client with a - // bounded per-request timeout is built. - HTTPClient *http.Client - // Debug, when true, dumps each request and response to DebugOut with - // the Authorization header redacted. - Debug bool - // DebugOut is where debug output is written. nil => os.Stderr. - DebugOut io.Writer - // MaxRetries caps the number of 429 retries. 0 => defaultMaxRetries. - // Negative values disable retry entirely. - MaxRetries int + HTTPClient *http.Client // nil => private client with defaultRequestTimeout + Debug bool + DebugOut io.Writer // nil => os.Stderr + MaxRetries int // 0 => defaultMaxRetries; negative => disable retry } // Client talks to the Emailable v1 API. @@ -56,14 +46,12 @@ type Client struct { maxRetries int } -// New returns a Client. When httpClient is nil a private *http.Client is -// constructed with a bounded per-request timeout; callers that need a -// different transport should pass their own. +// New returns a Client for baseURL authenticated with accessToken. func New(baseURL, accessToken string, httpClient *http.Client) *Client { return NewWithOptions(baseURL, accessToken, Options{HTTPClient: httpClient}) } -// NewWithOptions returns a Client configured per opts. +// NewWithOptions returns a Client for baseURL authenticated with accessToken, applying opts. func NewWithOptions(baseURL, accessToken string, opts Options) *Client { hc := opts.HTTPClient if hc == nil { @@ -103,6 +91,8 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, var lastErr error for attempt := 0; attempt <= c.maxRetries; attempt++ { + // Rebuilt each attempt: the form body Reader is single-use, so a retry + // needs a fresh one. var body io.Reader if len(form) > 0 { body = strings.NewReader(form.Encode()) @@ -139,11 +129,6 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, if err := json.Unmarshal(respBody, out); err != nil { return fmt.Errorf("decode response: %w", err) } - // Stash the response so machine output (--json, saved .json, stream - // events) can pass it through instead of re-encoding the typed - // struct and dropping nulls / unknown fields. respBody is a fresh - // per-call slice from io.ReadAll that nothing else aliases, so we - // hand it over directly rather than copying. if rr, ok := out.(rawReceiver); ok { rr.setRaw(respBody) } @@ -191,7 +176,6 @@ func backoffFor(rl *RateLimit, attempt int, now time.Time) time.Duration { if base == 0 { base = time.Duration(1< outgoing request\n%s\n\n", indentLines(string(dump))) } -// dumpResponse writes the response (with body) to c.debugOut when debug is on. func (c *Client) dumpResponse(resp *http.Response, body []byte) { if !c.debug { return } - // Splice the already-read body bytes back in so DumpResponse can emit them. + // Splice the already-read body back in so DumpResponse can emit it. clone := *resp clone.Body = io.NopCloser(strings.NewReader(string(body))) dump, err := httputil.DumpResponse(&clone, true) @@ -239,8 +219,6 @@ func (c *Client) dumpResponse(resp *http.Response, body []byte) { fmt.Fprintf(c.debugOut, "DEBUG <== incoming response\n%s\n\n", indentLines(string(dump))) } -// indentLines prefixes each line with two spaces so debug output is visually -// distinct from normal CLI text. func indentLines(s string) string { if s == "" { return s @@ -276,9 +254,6 @@ func parseRateLimit(h http.Header) *RateLimit { return rl } -// extractMessage pulls an error message from a JSON body, trying common keys -// in order: "message", "error", "error_description". Returns "" if none -// parsed successfully. func extractMessage(body []byte) string { var payload map[string]any if err := json.Unmarshal(body, &payload); err != nil { @@ -294,15 +269,14 @@ func extractMessage(body []byte) string { return "" } -// VerifyOptions tunes a single-email real-time verification request. Each -// field omitted (nil pointer / zero value) lets the server pick its default. +// VerifyOptions controls optional parameters for the Verify request. type VerifyOptions struct { - SMTP *bool // nil => server default (true). false disables SMTP probing. - AcceptAll *bool // nil => server default (false). true performs Accept-All detection. - Timeout int // seconds, 2-10. 0 => server default (5). + SMTP *bool // nil => server default + AcceptAll *bool // nil => server default + Timeout int // seconds 2-10; 0 => server default } -// Verify performs a real-time verification of a single email via GET /verify. +// Verify verifies a single email address via GET /verify. func (c *Client) Verify(ctx context.Context, email string, opts *VerifyOptions) (*VerifyResult, error) { q := url.Values{} q.Set("email", email) @@ -324,16 +298,14 @@ func (c *Client) Verify(ctx context.Context, email string, opts *VerifyOptions) return &out, nil } -// SubmitBatchOptions tunes a batch verification submission. Each field -// omitted (zero value / nil pointer) lets the server pick its default. +// SubmitBatchOptions controls optional parameters for the SubmitBatch request. type SubmitBatchOptions struct { - URL string // optional webhook URL the server POSTs to on completion - Retries *bool // nil => server default (true) - ResponseFields []string // optional subset of result fields to return; nil => all + URL string // webhook URL; empty => none + Retries *bool // nil => server default + ResponseFields []string // nil => all fields } -// SubmitBatch submits emails for batch verification via POST /batch and -// returns the new batch's id. +// SubmitBatch submits a list of emails for batch verification via POST /batch. func (c *Client) SubmitBatch(ctx context.Context, emails []string, opts *SubmitBatchOptions) (*BatchSubmit, error) { form := url.Values{} form.Set("emails", strings.Join(emails, ",")) @@ -355,8 +327,7 @@ func (c *Client) SubmitBatch(ctx context.Context, emails []string, opts *SubmitB return &out, nil } -// Batch fetches the current status (and, when complete or partial=true, -// per-email results) of a previously submitted batch via GET /batch?id=... +// Batch fetches the status of a batch by id via GET /batch. func (c *Client) Batch(ctx context.Context, id string, partial bool) (*BatchStatus, error) { q := url.Values{} q.Set("id", id) @@ -370,7 +341,7 @@ func (c *Client) Batch(ctx context.Context, id string, partial bool) (*BatchStat return &out, nil } -// Account fetches the authenticated user's account info via GET /account. +// Account fetches the authenticated account details via GET /account. func (c *Client) Account(ctx context.Context) (*Account, error) { var out Account if err := c.do(ctx, http.MethodGet, "/account", nil, nil, &out); err != nil { diff --git a/internal/api/errors.go b/internal/api/errors.go index 71da739..5f8188e 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -5,8 +5,7 @@ import ( "fmt" ) -// ErrUnauthenticated is returned when the API responds 401, signalling the -// stored access token is invalid or expired. +// ErrUnauthenticated is returned when the API responds with a 401 status. var ErrUnauthenticated = errors.New("api: not authenticated") // RateLimit captures the documented `RateLimit-*` response headers. All fields @@ -17,14 +16,12 @@ type RateLimit struct { Reset int // Unix timestamp, in seconds, when the window resets } -// Error is the typed error returned for non-2xx API responses. +// Error represents a non-2xx API response. type Error struct { StatusCode int Message string Body []byte - // RateLimit is set when the response carried any of the - // `RateLimit-*` headers (most commonly on 429). nil otherwise. - RateLimit *RateLimit + RateLimit *RateLimit // non-nil when response carried RateLimit-* headers } func (e *Error) Error() string { @@ -34,7 +31,6 @@ func (e *Error) Error() string { return fmt.Sprintf("HTTP %d", e.StatusCode) } -// Unwrap exposes ErrUnauthenticated for 401 responses. func (e *Error) Unwrap() error { if e.StatusCode == 401 { return ErrUnauthenticated diff --git a/internal/api/types.go b/internal/api/types.go index ee0caba..7969e56 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -1,31 +1,22 @@ package api -// rawReceiver is implemented by response types that retain the verbatim JSON -// body the server returned. Client.do calls setRaw after a successful decode so -// machine output can pass the original bytes through unchanged. +// rawReceiver is implemented by response types that cache the verbatim JSON body +// so machine output can pass it through unchanged (re-encoding drops nulls / unknown fields). type rawReceiver interface { setRaw([]byte) } -// rawJSON holds a captured response body. Embedding it gives a type the raw -// passthrough plumbing for free. The field is unexported so encoding/json -// never round-trips it back into output. +// rawJSON holds the captured response body; the field is unexported so +// encoding/json never round-trips it back into output. type rawJSON struct { raw []byte } func (r *rawJSON) setRaw(b []byte) { r.raw = b } -// RawJSON returns the verbatim response body the server sent, or nil if the -// value wasn't produced by an API call (e.g. constructed in a test). Callers -// emitting machine output should prefer these bytes over re-encoding. func (r *rawJSON) RawJSON() []byte { return r.raw } -// VerifyResult is the response from GET /v1/verify. -// -// The typed fields below drive human/CSV rendering. JSON output is served from -// the captured raw body (see rawJSON), so nullable fields and any field this -// struct doesn't model still pass through unchanged. +// VerifyResult is the response from GET /verify. type VerifyResult struct { rawJSON Email string `json:"email"` @@ -51,33 +42,22 @@ type VerifyResult struct { Duration float64 `json:"duration,omitempty"` } -// BatchSubmit is the response from POST /v1/batch. +// BatchSubmit is the response from POST /batch when a batch is created. type BatchSubmit struct { rawJSON ID string `json:"id"` Message string `json:"message"` } -// BatchTotalCounts is the `total_counts` object returned alongside a -// partial-results payload. Only Total and Processed are load-bearing for -// progress display. +// BatchTotalCounts holds aggregate progress counts from a partial batch response. type BatchTotalCounts struct { Total int `json:"total"` Processed int `json:"processed"` } -// BatchStatus is the response from GET /v1/batch. -// -// The API returns three distinct payload shapes that this struct merges: -// -// - In-progress (partial=false): top-level Total + Processed counts, no -// Emails, no DownloadFile. -// - Completed small batch (≤1000): Emails slice populated, Total/Processed -// dropped from the payload. -// - Completed large batch (>1000): DownloadFile URL only. -// - Partial snapshot (partial=true): Emails contains the rows ready so far, -// a top-level Message describes the partial state, and progress lives -// under TotalCounts (the top-level Total/Processed are NOT used). +// BatchStatus merges three API payload shapes: in-progress (Total/Processed), +// completed small batch (Emails), completed large batch (DownloadFile), and +// partial snapshot (TotalCounts — top-level Total/Processed are NOT set). type BatchStatus struct { rawJSON ID string `json:"id,omitempty"` @@ -91,11 +71,8 @@ type BatchStatus struct { TotalCounts *BatchTotalCounts `json:"total_counts,omitempty"` } -// IsComplete reports whether the batch has finished processing. -// -// TotalCounts is checked before the top-level counts because a partial-results -// payload omits the latter and would otherwise fall through to the -// Emails-populated branch and look complete prematurely. +// IsComplete checks TotalCounts before top-level counts: partial-results payloads +// omit the top-level counts, which would otherwise look complete prematurely. func (b *BatchStatus) IsComplete() bool { if b.DownloadFile != "" { return true @@ -112,9 +89,7 @@ func (b *BatchStatus) IsComplete() bool { return false } -// Progress returns (processed, total, ok). ok is false when the payload -// carries no progress counters (e.g. a completed small-batch payload that -// dropped them). +// Progress returns the processed and total counts for b, and ok=false when progress is unknown. func (b *BatchStatus) Progress() (processed, total int, ok bool) { if b.TotalCounts != nil { return b.TotalCounts.Processed, b.TotalCounts.Total, true @@ -125,7 +100,7 @@ func (b *BatchStatus) Progress() (processed, total int, ok bool) { return 0, 0, false } -// Account is the response from GET /v1/account. +// Account is the response from GET /account. type Account struct { rawJSON OwnerEmail string `json:"owner_email"` diff --git a/internal/config/config.go b/internal/config/config.go index 47aeb0b..d3e7159 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,8 +1,4 @@ -// Package config reads the emailable-cli non-secret config file, holding -// backend routing and output preferences. It has two scopes — a global file -// at $XDG_CONFIG_HOME/emailable/config.json and a project file at -// /.emailable/config.json — both user-managed (the CLI never writes -// them) and sharing the same schema. Credentials live in internal/credentials. +// Package config reads the non-secret config file (backend URLs, output format). The CLI never writes it. package config import ( @@ -16,8 +12,7 @@ import ( const appDir = "emailable" -// Config is the on-disk schema. All fields are optional. APIURL and OAuthURL -// must be set together within a single file. +// Config holds non-secret configuration (backend URLs and output format). type Config struct { APIURL string `json:"api_url,omitempty"` OAuthURL string `json:"oauth_url,omitempty"` @@ -27,11 +22,7 @@ type Config struct { Output string `json:"output,omitempty"` } -// DefaultPath returns the global config path: $XDG_CONFIG_HOME/emailable/config.json, -// falling back to $HOME/.config/emailable/config.json. -// -// Unlike credentials, this path is not env-suffixed — its contents are what -// determine the active env in the first place. +// DefaultPath is not env-suffixed — its contents are what determine the active env in the first place. func DefaultPath() (string, error) { base := os.Getenv("XDG_CONFIG_HOME") if base == "" { @@ -44,8 +35,7 @@ func DefaultPath() (string, error) { return filepath.Join(base, appDir, "config.json"), nil } -// Load returns an empty *Config when path doesn't exist or is zero bytes, -// so a fresh install or a `touch`ed file falls through to defaults. +// Load reads a Config from path, returning an empty Config if the file does not exist. func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go index cc077dc..f7076ec 100644 --- a/internal/credentials/credentials.go +++ b/internal/credentials/credentials.go @@ -1,13 +1,5 @@ // Package credentials reads and writes the global credentials file. -// -// Credentials are global by design: the CLI's login flow is interactive and -// machine-scoped. Per-project credentials are intentionally not supported — -// use the EMAILABLE_API_KEY environment variable for per-project / per-shell -// API keys. -// -// The file path is environment-suffixed (credentials.json for the default -// env, credentials..json for any other env) so logging in against an -// overridden backend does not clobber the default-env token. +// The file is environment-suffixed so tokens for different backends don't collide. package credentials import ( @@ -27,18 +19,7 @@ const ( dirMode os.FileMode = 0o700 ) -// Credentials is the on-disk schema for the global credentials file. Two -// auth modes can be persisted: -// -// - OAuth: AccessToken + RefreshToken + ExpiresAt + OwnerEmail, written -// by `emailable login` after the device flow and refreshed transparently -// by the CLI. -// - API key: APIKey, written by `emailable login --api-key ...` (or by -// piping a key into `emailable login`). -// -// At most one of the two is meaningful at a time; the schema tolerates any -// field being absent (older versions that didn't write it, an interrupted -// write, or loginWithAPIKey clearing the OAuth fields when saving a key). +// Credentials is the on-disk credentials schema for a single environment. type Credentials struct { AccessToken string `json:"access_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` @@ -47,9 +28,6 @@ type Credentials struct { APIKey string `json:"api_key,omitempty"` } -// fileName returns the credentials file name for envName. The default env -// uses credentials.json; any other env name gets a suffix so logins against -// a different backend do not collide. func fileName(envName string) string { if envName == "" || envName == "default" { return "credentials.json" @@ -57,8 +35,7 @@ func fileName(envName string) string { return "credentials." + envName + ".json" } -// DefaultPath honors XDG_CONFIG_HOME, falling back to $HOME/.config. envName -// is the active environment name from env.Current() ("default", "custom"). +// DefaultPath returns the credentials file path for the given environment name. func DefaultPath(envName string) (string, error) { base := os.Getenv("XDG_CONFIG_HOME") if base == "" { @@ -71,9 +48,7 @@ func DefaultPath(envName string) (string, error) { return filepath.Join(base, appDir, fileName(envName)), nil } -// Load returns an empty *Credentials when path doesn't exist (so a fresh -// install behaves the same as a logged-out one) or when the file is zero -// bytes (so `touch` or an interrupted login still parses). +// Load reads credentials from path, returning empty credentials if the file does not exist. func Load(path string) (*Credentials, error) { data, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { @@ -93,10 +68,7 @@ func Load(path string) (*Credentials, error) { return &c, nil } -// Save writes c to path atomically. Data is written to a temp file in the -// same directory with mode 0600, then renamed over the target. This prevents -// a partial write from corrupting an existing file and forces 0600 perms -// even when overwriting a file that had broader permissions. +// Save atomically writes c to path, creating parent directories as needed. func (c *Credentials) Save(path string) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, dirMode); err != nil { @@ -139,8 +111,7 @@ func (c *Credentials) Save(path string) error { return nil } -// Clear removes the credentials file at path. No-op when the file is absent -// so `logout` is idempotent. +// Clear removes the credentials file at path, ignoring a not-found error. func Clear(path string) error { err := os.Remove(path) if err != nil && !errors.Is(err, fs.ErrNotExist) { diff --git a/internal/env/env.go b/internal/env/env.go index dc3fe5a..8dba35b 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -1,18 +1,5 @@ -// Package env resolves the active runtime configuration: backend URLs and -// output format defaults. -// -// These live in a config.Config layered across three sources, in descending -// precedence: -// -// - Environment variables (EMAILABLE_API_URL, EMAILABLE_OAUTH_URL, -// EMAILABLE_OUTPUT). -// - Project file at /.emailable/config.json, discovered by -// walking up from the current working directory. -// - Global file at $XDG_CONFIG_HOME/emailable/config.json. -// -// All three use the same config.Config schema. Within a single source the -// API/OAuth URLs must be set together. Per-field, higher-precedence sources -// override lower-precedence ones. +// Package env resolves the active runtime configuration (backend URLs, output format) from env vars, +// project file, and global file in descending precedence. package env import ( @@ -24,12 +11,12 @@ import ( ) const ( - // PublicClientID is the OAuth client_id for the Emailable CLI. Public by - // OAuth spec for a "public client" — embedded in every distributed binary - // and registered with the same value on every Emailable environment. + // PublicClientID is embedded in every binary; public by OAuth spec for a native/CLI client. PublicClientID = "wdjuYuA3NZsKi-cR4mbaiBZ031iGt_a6zOpPQKzDFSI" - DefaultAPIBaseURL = "https://api.emailable.com/v1" + // DefaultAPIBaseURL is the production Emailable API base URL. + DefaultAPIBaseURL = "https://api.emailable.com/v1" + // DefaultOAuthBaseURL is the production Emailable OAuth base URL. DefaultOAuthBaseURL = "https://app.emailable.com" envAPIURL = "EMAILABLE_API_URL" @@ -38,22 +25,16 @@ const ( envOptOutNotifier = "EMAILABLE_NO_UPDATE_NOTIFIER" ) -// Environment holds the active host configuration. +// Environment holds the resolved runtime configuration for a single backend. type Environment struct { - // Name is "default" for production endpoints, "custom" when overridden via - // env vars, project file, or global file. Used to suffix the credentials - // file so tokens for different backends don't collide. + // Name suffixes the credentials file; "custom" when any URL is overridden so tokens don't collide. Name string APIBaseURL string OAuthBaseURL string ClientID string } -// MergedConfig returns the config that results from layering, per field: -// env vars (highest) > project file > global file > zero value (lowest). -// -// Within a single source, the api_url/oauth_url pair must be both-set or -// both-empty — partial sources are a configuration error. +// MergedConfig returns the configuration merged from the global file, project file, and environment variables. func MergedConfig() (*config.Config, error) { merged := &config.Config{} @@ -93,7 +74,6 @@ func MergedConfig() (*config.Config, error) { return merged, nil } -// applyOver overlays src onto dst — non-zero fields in src win. func applyOver(dst, src *config.Config) { if src.APIURL != "" { dst.APIURL = src.APIURL @@ -106,7 +86,7 @@ func applyOver(dst, src *config.Config) { } } -// Current resolves the active environment from the merged config. +// Current returns the active Environment resolved from config files and environment variables. func Current() (*Environment, error) { merged, err := MergedConfig() if err != nil { @@ -128,15 +108,11 @@ func Current() (*Environment, error) { }, nil } -// UpdateNotifierOptOut reports whether EMAILABLE_NO_UPDATE_NOTIFIER is set -// to a truthy value. Exposed separately from MergedConfig so callers can -// honor the env var even when config-file parsing fails — a corrupt config -// must not override the user's explicit opt-out. +// UpdateNotifierOptOut is separate from MergedConfig so a corrupt config file can't override an explicit opt-out. func UpdateNotifierOptOut() bool { return isTruthy(os.Getenv(envOptOutNotifier)) } -// isTruthy returns true for "1", "true", "yes", "on" (case-insensitive). func isTruthy(v string) bool { switch strings.ToLower(strings.TrimSpace(v)) { case "1", "true", "yes", "on": diff --git a/internal/env/projcfg.go b/internal/env/projcfg.go index 7d7d1bf..ed94bb3 100644 --- a/internal/env/projcfg.go +++ b/internal/env/projcfg.go @@ -8,26 +8,15 @@ import ( "github.com/emailable/emailable-cli/internal/config" ) -// Project-local config lives at /.emailable/config.json. The -// dotfile-prefixed dir keeps it tidy in repo roots and avoids colliding with -// the many unrelated tools that ship a bare config.json. const ( projectConfigDir = ".emailable" projectConfigFilename = "config.json" ) // findProjectConfig walks up from startDir looking for .emailable/config.json. -// Returns (path, found) where found=false means we walked all the way to the -// stopping point without finding the file. -// -// startDir is typically os.Getwd(). stopAt is the directory to stop walking -// at (typically the user's home dir). Walking continues past stopAt to the -// filesystem root only if startDir is not a descendant of stopAt — that lets -// a checkout living outside $HOME still find a config without leaking -// upward into siblings of the user's home. -// -// stopAt is a parameter (rather than always being $HOME) so tests can -// inject a sandbox root. +// Walking stops at stopAt only when startDir is a descendant of it, so checkouts +// outside $HOME still find a config without leaking into unrelated dirs. +// stopAt is a parameter so tests can inject a sandbox root. func findProjectConfig(startDir, stopAt string) (string, bool) { startDir = filepath.Clean(startDir) stopAt = filepath.Clean(stopAt) @@ -53,8 +42,6 @@ func findProjectConfig(startDir, stopAt string) (string, bool) { } } -// isDescendantOrEqual reports whether child is the same path as parent or -// nested somewhere underneath it. Both inputs should already be cleaned. func isDescendantOrEqual(child, parent string) bool { if child == parent { return true @@ -69,17 +56,12 @@ func isDescendantOrEqual(child, parent string) bool { return true } -// loadProjectConfig parses a discovered .emailable/config.json file. Returns -// an error if the file is malformed or has only one of the two URLs set (they -// must be set together within a single file). func loadProjectConfig(path string) (*config.Config, error) { cfg, err := config.Load(path) if err != nil { return nil, err } - // Half-set URLs within a single file are a user mistake; both must be set - // together. if (cfg.APIURL == "") != (cfg.OAuthURL == "") { return nil, fmt.Errorf("api_url and oauth_url must both be set") } @@ -87,16 +69,11 @@ func loadProjectConfig(path string) (*config.Config, error) { return cfg, nil } -// ProjectConfigPath finds the project-local .emailable/config.json by walking -// up from the current working directory. Returns ("", false) when none found. +// ProjectConfigPath returns the nearest project config path found by walking up from the current directory. func ProjectConfigPath() (string, bool) { return findProjectConfigFromCWD() } -// findProjectConfigFromCWD resolves os.Getwd() and the user's home directory -// and delegates to findProjectConfig. Returns found=false (without error) if -// either lookup fails — a missing cwd or unknown home shouldn't crash the -// CLI, it should just mean "no project config available". func findProjectConfigFromCWD() (string, bool) { cwd, err := os.Getwd() if err != nil { diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 982b930..d8763e5 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -1,9 +1,4 @@ -// Package oauth implements the OAuth 2.0 device authorization grant -// (RFC 8628) client used by emailable-cli's login flow, talking to the -// /oauth/* endpoints. -// -// The package is transport-only: it does not touch the config file or surface -// UX. +// Package oauth implements the OAuth 2.0 device authorization grant (RFC 8628) client. package oauth import ( @@ -17,20 +12,14 @@ import ( "time" ) -// grantTypeDeviceCode is the RFC 8628 grant_type value used when exchanging -// a device_code for an access token. const grantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code" -// minPollInterval is the floor for the device-code polling interval. RFC 8628 -// §3.2 says clients MUST default to 5 seconds when the server omits interval. +// minPollInterval: RFC 8628 §3.2 requires defaulting to 5 s when server omits interval. const minPollInterval = 5 * time.Second -// defaultRequestTimeout caps a single OAuth HTTP call. Per-request rather than -// per-loop, since PollToken runs many requests over an authorization's -// lifetime, but bounded so a stuck socket can't wedge the login flow. +// defaultRequestTimeout is per-request (not per-loop) so a stuck socket can't wedge the login flow. const defaultRequestTimeout = 30 * time.Second -// OAuth `error` field values the server may return from /oauth/token. const ( codeAuthorizationPending = "authorization_pending" codeSlowDown = "slow_down" @@ -39,33 +28,26 @@ const ( codeInvalidGrant = "invalid_grant" ) -// Exported sentinel errors so callers can detect specific failure modes via -// errors.Is. var ( + // ErrAccessDenied is returned when the user explicitly denies the authorization request. ErrAccessDenied = errors.New("oauth: access denied") + // ErrExpiredToken is returned when the device code has expired before the user authorized. ErrExpiredToken = errors.New("oauth: device code expired") - // ErrInvalidGrant signals the server rejected the refresh_token as no - // longer valid (rotated, revoked, or expired). Callers should treat this - // as "the user must log in again" rather than retry. + // ErrInvalidGrant is returned when the refresh token was rotated, revoked, or expired; caller must re-login. ErrInvalidGrant = errors.New("oauth: invalid grant") ) -// Client talks to the Emailable OAuth endpoints. +// Client performs OAuth 2.0 device authorization grant flows against appURL. type Client struct { httpClient *http.Client appURL string clientID string - // wait is overridable by tests so polling loops don't actually wait. - // The default honors ctx so Ctrl+C during a long sleep returns - // immediately instead of blocking for the full interval. + // wait is overridable by tests; default is ctx-aware so Ctrl+C during a poll sleep returns immediately. wait func(ctx context.Context, d time.Duration) error } // NewClient returns a Client that posts to appURL with the given clientID. -// When httpClient is nil a private *http.Client is constructed with a -// bounded per-request timeout; callers that need a different transport -// (e.g. tests) should pass their own. func NewClient(appURL, clientID string, httpClient *http.Client) *Client { if httpClient == nil { httpClient = &http.Client{Timeout: defaultRequestTimeout} @@ -78,7 +60,6 @@ func NewClient(appURL, clientID string, httpClient *http.Client) *Client { } } -// defaultWait sleeps for d or returns ctx's error if ctx is cancelled first. func defaultWait(ctx context.Context, d time.Duration) error { timer := time.NewTimer(d) defer timer.Stop() @@ -90,9 +71,6 @@ func defaultWait(ctx context.Context, d time.Duration) error { } } -// oauthError is the parsed `{ "error": ..., "error_description": ... }` -// body OAuth servers return on 4xx responses. Typed so callers can route on -// Code via errors.As. type oauthError struct { Code string Description string @@ -105,7 +83,7 @@ func (e *oauthError) Error() string { return e.Code } -// DeviceCode is the response from POST /oauth/device/code. +// DeviceCode holds the server response from the device authorization endpoint. type DeviceCode struct { DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` @@ -115,8 +93,7 @@ type DeviceCode struct { Interval int `json:"interval"` } -// Token is the response from POST /oauth/token. The same shape is returned -// for both the device_code grant and refresh_token grant. +// Token holds the access and refresh tokens returned by the token endpoint. type Token struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` @@ -126,8 +103,7 @@ type Token struct { CreatedAt int64 `json:"created_at"` } -// RequestDeviceCode returns a code pair whose user_code the human enters at -// verification_uri to authorize the CLI. +// RequestDeviceCode requests a device code from the authorization server. func (c *Client) RequestDeviceCode(ctx context.Context) (*DeviceCode, error) { form := url.Values{} form.Set("client_id", c.clientID) @@ -145,10 +121,7 @@ func (c *Client) RequestDeviceCode(ctx context.Context) (*DeviceCode, error) { return &dc, nil } -// PollToken exchanges a device_code for an access token. It loops until the -// server returns a token, the user denies, the code expires, or another -// non-pending error occurs. The polling interval starts at dc.Interval -// seconds and grows when the server signals slow_down. +// PollToken polls the token endpoint until the user authorizes, denies, or the code expires. func (c *Client) PollToken(ctx context.Context, dc *DeviceCode) (*Token, error) { form := url.Values{} form.Set("grant_type", grantTypeDeviceCode) @@ -189,12 +162,7 @@ func (c *Client) PollToken(ctx context.Context, dc *DeviceCode) (*Token, error) } } -// Refresh sends no client_secret because the CLI is a public OAuth client. -// -// On an invalid_grant response (refresh token rotated, revoked, or expired) -// the returned error wraps ErrInvalidGrant so callers can detect a -// permanently-dead refresh token via errors.Is and prompt re-login. Other -// failures (network, 5xx, decode) propagate as-is. +// Refresh sends no client_secret — the CLI is a public OAuth client. func (c *Client) Refresh(ctx context.Context, refreshToken string) (*Token, error) { form := url.Values{} form.Set("grant_type", "refresh_token") @@ -211,7 +179,7 @@ func (c *Client) Refresh(ctx context.Context, refreshToken string) (*Token, erro return tok, nil } -// Revoke invalidates accessToken via POST /oauth/revoke. +// Revoke revokes the given access token at the authorization server. func (c *Client) Revoke(ctx context.Context, accessToken string) error { form := url.Values{} form.Set("token", accessToken) @@ -225,8 +193,6 @@ func (c *Client) Revoke(ctx context.Context, accessToken string) error { return nil } -// tokenPost wraps formPost with Token decoding. Server errors arrive as a -// wrapped *oauthError. func (c *Client) tokenPost(ctx context.Context, form url.Values) (*Token, error) { resp, err := c.formPost(ctx, "/oauth/token", form, "token") if err != nil { @@ -241,11 +207,6 @@ func (c *Client) tokenPost(ctx context.Context, form url.Values) (*Token, error) return &tok, nil } -// formPost POSTs form to c.appURL+path as application/x-www-form-urlencoded. -// On 2xx returns the response with body still open; the caller must close -// it. On non-2xx closes the body internally and returns a parsed OAuth -// error. The op string ("device code", "token", "revoke") is included in -// the wrapped error message. func (c *Client) formPost(ctx context.Context, path string, form url.Values, op string) (*http.Response, error) { req, err := http.NewRequestWithContext( ctx, @@ -269,8 +230,6 @@ func (c *Client) formPost(ctx context.Context, path string, form url.Values, op return resp, nil } -// wrapSentinel keeps the sentinel detectable via errors.Is while attaching -// the server's error_description for the human-readable message. func wrapSentinel(sentinel error, description string) error { if description == "" { return sentinel @@ -278,9 +237,6 @@ func wrapSentinel(sentinel error, description string) error { return fmt.Errorf("%w: %s", sentinel, description) } -// parseOAuthError reads an OAuth-style error body from resp and returns it as -// a typed *oauthError so the server's `error` / `error_description` fields -// surface verbatim, without layering a package-specific prefix on top. func parseOAuthError(resp *http.Response, op string) error { var body struct { Error string `json:"error"` diff --git a/internal/output/account_view.go b/internal/output/account_view.go index e511b63..7844b70 100644 --- a/internal/output/account_view.go +++ b/internal/output/account_view.go @@ -1,10 +1,8 @@ package output -// AccountView is the account summary: owner email plus available credits. -// -// Fields are flattened (rather than embedding the source struct) to keep the -// JSON shape an explicit, stable contract for downstream consumers — embedding -// would nest or distort the output since encoding/json has no "inline" tag. +// AccountView holds the account summary fields. Flattened rather than +// embedding the source struct — encoding/json has no "inline" tag and +// embedding would nest or distort the JSON shape. type AccountView struct { OwnerEmail string `json:"owner_email"` AvailableCredits int `json:"available_credits"` diff --git a/internal/output/file.go b/internal/output/file.go index 5605e84..9be9e55 100644 --- a/internal/output/file.go +++ b/internal/output/file.go @@ -13,16 +13,12 @@ import ( // SaveOptions controls how WriteResults serializes data to disk. type SaveOptions struct { - // Path is the destination file path; required. - Path string - // ForceJSON forces JSON output regardless of file extension. + Path string ForceJSON bool - // Stderr receives non-fatal notes. nil means os.Stderr. - Stderr *os.File + Stderr *os.File // nil means os.Stderr } -// csvHeader is the canonical CSV column order. Floats (like Duration) are -// intentionally omitted — not useful in a spreadsheet. +// Duration and other floats are omitted — not useful in a spreadsheet. var csvHeader = []string{ "email", "state", "score", "reason", "domain", "disposable", "accept_all", "role", "free", @@ -30,12 +26,8 @@ var csvHeader = []string{ "first_name", "last_name", "gender", } -// WriteResults writes v to opts.Path atomically (via .tmp + rename) and -// returns the number of result rows written. -// -// Format selection: ForceJSON or a .json extension yields JSON; .csv yields -// CSV when v is flattenable and JSON otherwise; any other extension yields -// JSON. Non-JSON fallbacks emit a stderr note. Files are mode 0644. +// WriteResults writes v to opts.Path atomically and returns the row count. +// Unknown extensions fall back to JSON with a stderr note. func WriteResults(v any, opts SaveOptions) (int, error) { if opts.Path == "" { return 0, fmt.Errorf("output path is required") @@ -56,7 +48,6 @@ func WriteResults(v any, opts SaveOptions) (int, error) { case ext == ".json": useCSV = false default: - // Unknown extension: warn and write JSON. fmt.Fprintln(stderr, "note: unrecognized extension; writing JSON") useCSV = false } @@ -72,8 +63,6 @@ func WriteResults(v any, opts SaveOptions) (int, error) { return writeJSON(v, opts.Path) } -// flattenForCSV returns the rows extractable from v and whether v is a -// CSV-renderable shape at all. func flattenForCSV(v any) ([]api.VerifyResult, bool) { switch t := v.(type) { case *api.VerifyResult: @@ -97,8 +86,6 @@ func flattenForCSV(v any) ([]api.VerifyResult, bool) { } } -// resultCount returns the row count for the JSON path so callers can print -// "Saved N results" consistently regardless of format. func resultCount(v any) int { switch t := v.(type) { case *api.VerifyResult: @@ -118,8 +105,6 @@ func resultCount(v any) int { case []api.VerifyResult: return len(t) default: - // No row count for unknown shapes; callers reword their success - // message ("Saved to ") accordingly. return 0 } } @@ -141,8 +126,6 @@ func writeCSV(rows []api.VerifyResult, path string) (int, error) { if err != nil { return 0, fmt.Errorf("create %s: %w", tmp, err) } - // Best-effort cleanup of the tmp file if anything below fails before - // rename. cleanup := true defer func() { if cleanup { @@ -193,8 +176,6 @@ func writeCSV(rows []api.VerifyResult, path string) (int, error) { return len(rows), nil } -// atomicWrite writes data to path via path+".tmp" and renames into place. -// The tmp file is removed if rename fails. Mode is 0644. func atomicWrite(path string, data []byte) error { tmp := path + ".tmp" cleanup := true diff --git a/internal/output/human.go b/internal/output/human.go index 2076b45..de1cff6 100644 --- a/internal/output/human.go +++ b/internal/output/human.go @@ -12,21 +12,15 @@ import ( ) // Human renders for a terminal, type-switching on the value to produce -// labeled blocks, status cards, and tables. -// -// Colors are emitted only when w is a TTY. We gate each render on the actual -// writer rather than trusting lipgloss's default renderer, which probes -// stderr/stdout globally. +// labeled blocks, status cards, and tables. Colors gate on the actual writer +// rather than lipgloss's default renderer, which probes stderr/stdout globally. type Human struct { W io.Writer - // Quiet, when true, suppresses the "chrome" methods (Success / Hint / - // Notice). Error rendering and the typed Print* methods are unaffected — - // a quiet batch table still prints the table. + // Quiet suppresses Success/Hint/Notice; typed Print* methods still run. Quiet bool } -// Success prints a one-line confirmation styled as `✓ ` — green ✓, bold -// message. No-ops when Quiet is true. +// Success prints a bold green check mark followed by msg. func (h *Human) Success(msg string) error { if h.Quiet { return nil @@ -38,9 +32,7 @@ func (h *Human) Success(msg string) error { return err } -// Notice prints a single dimmed informational line — no leading blank, no -// glyph — for in-band status messages. Backtick segments render a shade -// lighter so commands/codes stand out. No-ops when Quiet is true. +// Notice prints msg in dim text, rendering backtick-delimited spans in a lighter color. func (h *Human) Notice(msg string) error { if h.Quiet { return nil @@ -61,10 +53,7 @@ func (h *Human) Notice(msg string) error { return err } -// Hint prints a dimmed follow-up line preceded by a blank line so it -// separates from the output above it. Backtick segments render a shade -// lighter (commands/flags stand out) and the backticks are stripped. -// No-ops when Quiet is true. +// Hint prints a dimmed follow-up tip, preceded by a blank line. func (h *Human) Hint(msg string) error { if h.Quiet { return nil @@ -85,7 +74,7 @@ func (h *Human) Hint(msg string) error { return err } -// Print dispatches on the runtime type of v. +// Print renders v to h.W, dispatching on v's runtime type. func (h *Human) Print(v any) error { switch x := v.(type) { case *api.VerifyResult: @@ -123,17 +112,15 @@ func (h *Human) Print(v any) error { } } -// State palette mirroring the dashboard's brand colors. Lipgloss -// auto-degrades to the nearest 256-color value where truecolor is unsupported. +// Hex values match the dashboard brand palette; lipgloss degrades to nearest +// 256-color where truecolor is unsupported. const ( - colorDeliverable = lipgloss.Color("42") // green - colorUndeliverable = lipgloss.Color("#EE6F84") // dashboard coral-pink - colorRisky = lipgloss.Color("214") // yellow/orange - colorUnknown = lipgloss.Color("#7EB7DE") // dashboard powder-blue + colorDeliverable = lipgloss.Color("42") + colorUndeliverable = lipgloss.Color("#EE6F84") + colorRisky = lipgloss.Color("214") + colorUnknown = lipgloss.Color("#7EB7DE") ) -// stateColor returns a lipgloss color for a verification state value; the -// empty color means "no color". func stateColor(state string) lipgloss.Color { switch state { case "deliverable": @@ -149,8 +136,6 @@ func stateColor(state string) lipgloss.Color { } } -// stateGlyph returns a leading glyph for the state: ✓ deliverable, -// ✗ undeliverable, ! risky, ? unknown. func stateGlyph(state string) string { switch state { case "deliverable": @@ -166,10 +151,7 @@ func stateGlyph(state string) string { } } -// hyperlink wraps text in an OSC 8 escape sequence so supporting terminals -// (iTerm2, kitty, alacritty, recent gnome-terminal, etc) render it as a -// clickable link. Older terminals ignore the escape and just print text. -// When enabled is false, returns text unchanged. +// hyperlink wraps text in an OSC 8 escape; unsupporting terminals ignore it. func hyperlink(url, text string, enabled bool) string { if !enabled || url == "" { return text @@ -177,32 +159,25 @@ func hyperlink(url, text string, enabled bool) string { return "\x1b]8;;" + url + "\x1b\\" + text + "\x1b]8;;\x1b\\" } -// isTTY reports whether w is a terminal AND ANSI styling is enabled (NO_COLOR -// suppresses it). func isTTY(w io.Writer) bool { return ui.IsTTY(w) } -// styler returns a lipgloss style configured for the writer's color -// capability. When w isn't a TTY all styles render as plain text. func styler(w io.Writer) func(lipgloss.Style) lipgloss.Style { tty := isTTY(w) return func(s lipgloss.Style) lipgloss.Style { if !tty { - // Empty style renders as raw input — strips styling. return lipgloss.NewStyle() } return s } } -// StylerFor is the exported form of styler for callers outside this package -// that need the same TTY-gated rendering. +// StylerFor returns a style transformer that strips styles when w is not a TTY. func StylerFor(w io.Writer) func(lipgloss.Style) lipgloss.Style { return styler(w) } -// yesNo converts a bool to "Yes" or "No". func yesNo(b bool) string { if b { return "Yes" @@ -210,8 +185,8 @@ func yesNo(b bool) string { return "No" } -// titleFirst capitalizes the first byte of s. Not Unicode-aware on purpose: -// inputs are ASCII tokens (state, gender) per the API. +// titleFirst capitalizes the first byte of s; intentionally not Unicode-aware +// since API tokens (state, gender) are ASCII. func titleFirst(s string) string { if s == "" { return "" @@ -219,13 +194,10 @@ func titleFirst(s string) string { return strings.ToUpper(s[:1]) + s[1:] } -// humanizeState capitalizes the first letter of a lowercase API state value. func humanizeState(s string) string { return titleFirst(s) } -// humanizeReason maps snake_case API reason codes to display labels. -// Unknown codes are returned unchanged so data isn't lost. func humanizeReason(r string) string { switch r { case "accepted_email": @@ -255,8 +227,8 @@ func humanizeReason(r string) string { } } -// scoreDisplay returns the user-facing score string. The "unknown" state's -// numeric score isn't meaningful (API reports 0), so it renders an em-dash. +// scoreDisplay renders an em-dash for "unknown" — the API reports 0, which +// would be misleading. func scoreDisplay(score int, state string) string { if state == "unknown" { return "—" @@ -264,9 +236,6 @@ func scoreDisplay(score int, state string) string { return strconv.Itoa(score) } -// scoreBadgeBG returns the background color for the score "pill". Bands -// mirror the dashboard: green high, yellow risky, coral-pink zero, powder-blue -// unknown. Shares the stateColor palette so badge and State row read as a set. func scoreBadgeBG(score int, state string) lipgloss.Color { if state == "unknown" { return colorUnknown @@ -281,10 +250,9 @@ func scoreBadgeBG(score int, state string) lipgloss.Color { } } -// PrintVerifyResult renders a single verify result as a header line (email + -// colored score badge) followed by "General", "Attributes", and "Mail Server" -// sections. Optional rows and empty sections are skipped. The `user` and -// `duration` fields are intentionally omitted (JSON output retains them). +// PrintVerifyResult renders a single verify result. The `user` and `duration` +// fields are intentionally omitted here; JSON output retains them. +// PrintVerifyResult renders r as a labeled card with state, score, and attribute sections. func (h *Human) PrintVerifyResult(r *api.VerifyResult) error { stf := styler(h.W) labelStyle := stf(lipgloss.NewStyle().Foreground(lipgloss.Color("241"))) @@ -320,7 +288,6 @@ func (h *Human) PrintVerifyResult(r *api.VerifyResult) error { general.rows = append(general.rows, row{"Gender", titleFirst(r.Gender)}) } if r.State != "" { - // Colored icon badge followed by the state name in the same color. stateBadgeStyle := stf(lipgloss.NewStyle(). Background(stateColor(r.State)). Foreground(lipgloss.Color("0")). @@ -336,8 +303,6 @@ func (h *Human) PrintVerifyResult(r *api.VerifyResult) error { general.rows = append(general.rows, row{"Reason", humanizeReason(r.Reason)}) } if r.Domain != "" { - // OSC 8 hyperlink on a TTY, with cyan+underline styling as a fallback - // for terminals that don't honor OSC 8. linkStyle := stf(lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Underline(true)) domainText := linkStyle.Render(r.Domain) general.rows = append(general.rows, row{ @@ -373,7 +338,6 @@ func (h *Human) PrintVerifyResult(r *api.VerifyResult) error { sections := []section{general, attrs, mail} - // Compute label width across all rendered rows for clean alignment. width := 0 for _, s := range sections { for _, rr := range s.rows { @@ -383,7 +347,6 @@ func (h *Human) PrintVerifyResult(r *api.VerifyResult) error { } } - // Header: " [score]" if r.Email != "" { glyph := stateGlyph(r.State) header := emailStyle.Render(r.Email) @@ -420,10 +383,7 @@ func (h *Human) PrintVerifyResult(r *api.VerifyResult) error { return nil } -// PrintBatchStatus renders the batch status as a compact "status card": a -// glyph + bold ID on line 1, a dimmed "processed/total (pct%)" counter on -// line 2. When no progress counters are present the counter becomes -// "(starting...)". +// PrintBatchStatus renders the ID and progress of an in-flight batch. func (h *Human) PrintBatchStatus(s *api.BatchStatus) error { stf := styler(h.W) idStyle := stf(lipgloss.NewStyle().Bold(true)) @@ -453,9 +413,7 @@ func (h *Human) PrintBatchStatus(s *api.BatchStatus) error { return err } -// PrintBatchSummary renders a one-line outcome summary with per-state counts. -// The leading glyph and verb reflect completion: "✓ Verified N emails: …" when -// done, "⋯ Partial results (M of N processed): …" while in-flight. +// PrintBatchSummary renders a one-line state-breakdown summary for a batch. func (h *Human) PrintBatchSummary(s *api.BatchStatus) error { stf := styler(h.W) @@ -489,8 +447,6 @@ func (h *Human) PrintBatchSummary(s *api.BatchStatus) error { return err } - // Partial / in-flight: a blue-ish glyph matching the status card for a - // consistent "still working" signal. glyph := stf(lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Bold(true)).Render("⋯") processed, total, hasProgress := s.Progress() progressNote := "" @@ -501,9 +457,7 @@ func (h *Human) PrintBatchSummary(s *api.BatchStatus) error { return err } -// PrintBatchResults renders the per-email results table (columns EMAIL, SCORE, -// STATE, REASON). SCORE and STATE use colored pills like the single-verify -// card. Column widths use lipgloss.Width so ANSI codes don't skew alignment. +// PrintBatchResults renders results as a color-coded table with email, score, state, and reason columns. func (h *Human) PrintBatchResults(results []api.VerifyResult) error { stf := styler(h.W) headStyle := stf(lipgloss.NewStyle().Foreground(lipgloss.Color("241"))) @@ -527,9 +481,6 @@ func (h *Human) PrintBatchResults(results []api.VerifyResult) error { Padding(0, 1)). Render(stateGlyph(r.State)) - // Fixed-width 5-column pill (1 gutter + 3 right-aligned content + 1 - // gutter). Gutters are baked into the rendered string rather than - // using lipgloss Padding so the background spans the full width. scoreText := scoreDisplay(r.Score, r.State) if pad := 3 - lipgloss.Width(scoreText); pad > 0 { scoreText = strings.Repeat(" ", pad) + scoreText @@ -548,8 +499,6 @@ func (h *Human) PrintBatchResults(results []api.VerifyResult) error { }) } - // Column widths from rendered cells (lipgloss.Width strips ANSI and counts - // visual columns). widths := make([]int, len(headers)) for i, hd := range headerCells { widths[i] = lipgloss.Width(hd) @@ -572,7 +521,6 @@ func (h *Human) PrintBatchResults(results []api.VerifyResult) error { var b strings.Builder - // Header for i, c := range headerCells { b.WriteString(c) b.WriteString(padSpaces(c, widths[i])) @@ -582,7 +530,6 @@ func (h *Human) PrintBatchResults(results []api.VerifyResult) error { } b.WriteString("\n") - // Dimmed separator using a box-drawing horizontal line. for i, w := range widths { b.WriteString(dimStyle.Render(strings.Repeat("─", w))) if i < len(widths)-1 { @@ -591,7 +538,6 @@ func (h *Human) PrintBatchResults(results []api.VerifyResult) error { } b.WriteString("\n") - // Body rows for _, row := range rows { for i, c := range row { b.WriteString(c) @@ -607,7 +553,6 @@ func (h *Human) PrintBatchResults(results []api.VerifyResult) error { return err } -// formatThousands renders an integer with comma thousands separators. func formatThousands(n int) string { s := strconv.Itoa(n) neg := false @@ -642,8 +587,7 @@ func formatThousands(n int) string { return out } -// PrintAccountView renders the account summary: dimmed labels with bold email -// and credit values. +// PrintAccountView renders the account owner email and available credit balance. func (h *Human) PrintAccountView(v *AccountView) error { stf := styler(h.W) label := stf(lipgloss.NewStyle().Foreground(lipgloss.Color("241"))) diff --git a/internal/output/jq.go b/internal/output/jq.go index 6882fc7..f97d4fb 100644 --- a/internal/output/jq.go +++ b/internal/output/jq.go @@ -4,11 +4,12 @@ import ( "github.com/itchyny/gojq" ) -// Query wraps a compiled gojq program, keeping gojq out of the cmd layer. +// Query holds a compiled jq expression ready to filter JSON values. type Query struct { code *gojq.Code } +// CompileQuery parses and compiles expr into a reusable Query. func CompileQuery(expr string) (*Query, error) { parsed, err := gojq.Parse(expr) if err != nil { @@ -30,7 +31,7 @@ func (q *Query) run(input any) ([]any, error) { break } if err, ok := v.(error); ok { - // A bare halt/halt_error ends the stream without erroring, per jq. + // halt/halt_error with no value ends the stream cleanly, per jq semantics. if he, ok := err.(*gojq.HaltError); ok && he.Value() == nil { break } diff --git a/internal/output/json.go b/internal/output/json.go index 7b7e391..9ba7bce 100644 --- a/internal/output/json.go +++ b/internal/output/json.go @@ -8,15 +8,10 @@ import ( "github.com/emailable/emailable-cli/internal/ui" ) -// RawJSONProvider is implemented by API response types that retain the server -// response. When a value carries raw bytes, machine output is built from those -// instead of re-encoding the typed struct, so nullable fields and any field the -// struct doesn't model are preserved — the contract the README advertises. -// -// This is a structural passthrough, not byte-for-byte: every key, value, null, -// and field order is preserved, but insignificant whitespace is normalized to -// the formatter's shape (see documentBytes) so output stays consistent and -// colorizable regardless of how the API formatted the body. +// RawJSONProvider is implemented by API response types that retain the raw +// server body. Using it instead of re-encoding the typed struct preserves +// nullable fields and unmodeled keys — the contract the README advertises. +// Whitespace is normalized to the formatter's shape (compact vs. pretty). type RawJSONProvider interface { RawJSON() []byte } @@ -33,24 +28,20 @@ func rawBytes(v any) ([]byte, bool) { return raw, true } -// JSON renders a value as JSON — pretty by default, one line when Compact, -// colorized on a TTY. A set Query (--jq) filters the value before printing. +// JSON renders a value as JSON, optionally filtering with a jq query. type JSON struct { W io.Writer Query *Query Compact bool } -// FilterError distinguishes a --jq runtime error from an I/O error, so the -// streaming path can skip an event whose filter errored instead of aborting. +// FilterError distinguishes a --jq runtime error from an I/O error so the +// streaming path can skip a failed event instead of aborting. type FilterError struct{ Err error } func (e *FilterError) Error() string { return e.Err.Error() } func (e *FilterError) Unwrap() error { return e.Err } -// marshalDocument renders v's JSON document — the raw API body when present -// (only whitespace reshaped, so unmodeled fields and nulls survive), else the -// typed encoding. Shared by the formatter and file writes so they can't drift. func marshalDocument(v any, compact bool) ([]byte, error) { if raw, ok := rawBytes(v); ok { var buf bytes.Buffer @@ -64,7 +55,7 @@ func marshalDocument(v any, compact bool) ([]byte, error) { buf.WriteByte('\n') return buf.Bytes(), nil } - // Malformed raw: fall through to typed encoding below. + // Malformed raw bytes — fall through to typed encoding. } var buf bytes.Buffer enc := json.NewEncoder(&buf) @@ -81,6 +72,7 @@ func (j *JSON) documentBytes(v any) ([]byte, error) { return marshalDocument(v, j.Compact) } +// Print writes v as JSON to j.W, applying j.Query if set. func (j *JSON) Print(v any) error { if j.Query != nil { return j.printFiltered(v) @@ -96,8 +88,6 @@ func (j *JSON) Print(v any) error { return err } -// printFiltered writes each --jq result on its own line. Strings print raw -// (unquoted, like `jq -r`); everything else as JSON. func (j *JSON) printFiltered(v any) error { doc, err := j.documentBytes(v) if err != nil { @@ -137,8 +127,7 @@ func (j *JSON) printFiltered(v any) error { func (j *JSON) encodeResult(r any) ([]byte, error) { var buf bytes.Buffer enc := json.NewEncoder(&buf) - // HTML escaping off so URLs and angle brackets survive, the way jq emits them. - enc.SetEscapeHTML(false) + enc.SetEscapeHTML(false) // match jq: URLs and angle brackets pass through unescaped if !j.Compact { enc.SetIndent("", " ") } @@ -148,10 +137,8 @@ func (j *JSON) encodeResult(r any) ([]byte, error) { return bytes.TrimRight(buf.Bytes(), "\n"), nil } -// ANSI escape sequences for the JSON palette. Raw codes (not lipgloss): the -// global lipgloss renderer probes process stdio at init and can drop a -// TTY-detected writer to uncolored output, whereas raw codes are deterministic -// and testable. +// Raw ANSI codes rather than lipgloss: lipgloss probes process stdio at init +// and can drop color for a TTY-detected writer; raw codes are deterministic. const ( jsonAnsiReset = "\x1b[0m" jsonAnsiKey = "\x1b[1;36m" // bold cyan @@ -161,15 +148,9 @@ const ( jsonAnsiNull = "\x1b[2m" // dim ) -// colorizeJSON wraps each JSON token in src with ANSI styling. src must be -// well-formed JSON (the bytes coming out of encoding/json), so this is a -// pragmatic scanner rather than a full parser: strings are extracted with -// escape-aware bounds, numbers are walked greedily over [0-9.eE+-], and -// `true`/`false`/`null` are matched as literals. Anything else (whitespace, -// structural punctuation) is copied through unchanged. -// -// Key vs string disambiguation: after closing a string we peek past any -// spaces/tabs; a following ':' means we just rendered an object key. +// colorizeJSON applies ANSI color to well-formed JSON from encoding/json. +// Key vs. value disambiguation: after closing a string, peek past spaces/tabs; +// a ':' means the string was an object key. func colorizeJSON(src []byte) []byte { out := bytes.NewBuffer(make([]byte, 0, len(src)*2)) for i := 0; i < len(src); { @@ -178,8 +159,6 @@ func colorizeJSON(src []byte) []byte { case c == '"': end := scanString(src, i) tok := src[i:end] - // A following ':' (past spaces/tabs only — no newlines appear - // between key and colon) means this string is an object key. k := end for k < len(src) && (src[k] == ' ' || src[k] == '\t') { k++ @@ -211,24 +190,19 @@ func colorizeJSON(src []byte) []byte { return out.Bytes() } -// wrap writes prefix + tok + reset to out. func wrap(out *bytes.Buffer, prefix string, tok []byte) { out.WriteString(prefix) out.Write(tok) out.WriteString(jsonAnsiReset) } -// scanString returns the index one past the closing quote of the JSON -// string starting at src[start] (which must be '"'). Backslash escapes are -// skipped so an escaped quote (\") doesn't terminate the string early. +// scanString returns the index one past the closing quote. Backslash escapes +// skip two bytes so \" doesn't terminate early. func scanString(src []byte, start int) int { i := start + 1 for i < len(src) { switch src[i] { case '\\': - // Skip the escape and the byte it escapes. For \uXXXX the four - // hex digits get walked as ordinary unescaped bytes in the next - // iteration — that's fine; none of them are '"' or '\\'. i += 2 case '"': return i + 1 @@ -239,10 +213,6 @@ func scanString(src []byte, start int) int { return i } -// scanNumber returns the index one past the last byte of the JSON number -// starting at src[start]. The accepted character set is intentionally a -// superset (it would match malformed numbers too), but encoding/json only -// emits valid numbers, so over-acceptance is harmless here. func scanNumber(src []byte, start int) int { i := start + 1 for i < len(src) { @@ -256,7 +226,6 @@ func scanNumber(src []byte, start int) int { return i } -// hasLiteral reports whether src starting at i exactly matches lit. func hasLiteral(src []byte, i int, lit string) bool { if i+len(lit) > len(src) { return false diff --git a/internal/output/output.go b/internal/output/output.go index 3676c03..f67e5fb 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -1,8 +1,5 @@ -// Package output handles terminal output formatting. Two formats are -// supported: JSON (machine-readable) and Human (TTY-colored, table-style). -// -// Callers pick the format from the persistent --json flag and call Print on -// the resulting Formatter. +// Package output handles terminal output formatting (Human TTY-colored tables +// and machine-readable JSON), selected via the persistent --json flag. package output import "io" @@ -12,7 +9,7 @@ type Formatter interface { Print(v any) error } -// New returns a JSON formatter when jsonMode is true, otherwise a Human one. +// New returns a JSON formatter when jsonMode is true, otherwise a Human formatter. func New(w io.Writer, jsonMode bool) Formatter { if jsonMode { return &JSON{W: w} diff --git a/internal/skill/skill.go b/internal/skill/skill.go index 5cb8782..0f8228c 100644 --- a/internal/skill/skill.go +++ b/internal/skill/skill.go @@ -1,6 +1,4 @@ -// Package skill installs the embedded SKILL.md into agent skill dirs. -// Canonical copy lives at ~/.agents/skills/emailable/; other targets -// symlink back so re-installing on upgrade touches one place. +// Package skill installs the embedded SKILL.md into agent skill dirs; other targets symlink the canonical copy. package skill import ( @@ -14,14 +12,14 @@ import ( ) const ( - Name = "emailable" + // Name is the skill name used as the directory and embed path. + Name = "emailable" + // FileName is the skill document filename written into each target directory. FileName = "SKILL.md" canonicalTargetID = "agents-shared" ) -// Location is one known install target. Dir may begin with "~/". -// Detect, when non-nil, gates auto-linking on whether the agent looks -// installed. +// Location describes a skill installation target (directory and detection predicate). type Location struct { ID string Name string @@ -30,8 +28,7 @@ type Location struct { Detect func() bool } -// Targets is recomputed each call so CODEX_HOME and CWD-relative -// project paths reflect current state. +// Targets returns all known skill installation locations. func Targets() []Location { return []Location{ {ID: canonicalTargetID, Name: "Agents (Shared)", Dir: "~/.agents/skills/" + Name, Global: true, Detect: dirExists("~/.agents")}, @@ -43,6 +40,7 @@ func Targets() []Location { } } +// LookupTarget returns the Location with the given ID, or false if not found. func LookupTarget(id string) (Location, bool) { for _, t := range Targets() { if t.ID == id { @@ -52,13 +50,13 @@ func LookupTarget(id string) (Location, bool) { return Location{}, false } +// Canonical returns the canonical (shared agents) installation location. func Canonical() Location { loc, _ := LookupTarget(canonicalTargetID) return loc } -// Content panics on miss — go:embed verifies at compile time, so a -// runtime read failure is a broken build. +// Content panics on miss: go:embed guarantees the file exists, so a runtime failure means a broken build. func Content() string { data, err := skills.FS.ReadFile(Name + "/" + FileName) if err != nil { @@ -67,17 +65,20 @@ func Content() string { return string(data) } +// Result holds the outcome of an install operation. type Result struct { SkillPath string Links []LinkResult } +// LinkResult describes a single symlink (or copy fallback) created during install. type LinkResult struct { Target Location Path string Notice string // non-empty when symlink fell back to a copy } +// InstallToDir writes SKILL.md into dir, creating the directory if needed. func InstallToDir(dir string) (string, error) { abs, err := Expand(dir) if err != nil { @@ -93,6 +94,7 @@ func InstallToDir(dir string) (string, error) { return file, nil } +// InstallToFile writes SKILL.md to the exact file path, creating parent directories if needed. func InstallToFile(path string) (string, error) { abs, err := Expand(path) if err != nil { @@ -107,9 +109,7 @@ func InstallToFile(path string) (string, error) { return abs, nil } -// NormalizeCustomPath turns a free-form path into a SKILL.md file -// path: .md verbatim, /emailable[/] gets SKILL.md appended, anything -// else gets emailable/SKILL.md appended. +// NormalizeCustomPath returns the canonical SKILL.md file path for a user-supplied directory or file path. func NormalizeCustomPath(input string) string { p := strings.TrimSpace(input) if strings.HasSuffix(strings.ToLower(p), ".md") { @@ -122,26 +122,27 @@ func NormalizeCustomPath(input string) string { return filepath.Join(p, Name, FileName) } +// InstallCanonical installs SKILL.md to the canonical shared-agents directory. func InstallCanonical() (string, error) { return InstallToDir(Canonical().Dir) } -// InstallDetected links only global agents whose dirs already exist. -// Project targets are skipped — CWD may not be the project the user -// meant. +// InstallDetected links only global agents whose dirs already exist; project targets are skipped +// because CWD may not be the intended project. func InstallDetected() (Result, error) { return installMany(func(t Location) bool { return t.Global && t.ID != canonicalTargetID && (t.Detect == nil || t.Detect()) }) } -// InstallAll links every global target, detected or not. +// InstallAll installs SKILL.md to all known global targets, detected or not. func InstallAll() (Result, error) { return installMany(func(t Location) bool { return t.Global && t.ID != canonicalTargetID }) } +// InstallOne installs SKILL.md to a single explicit target location. func InstallOne(target Location) (Result, error) { return installMany(func(t Location) bool { return t.ID == target.ID && t.ID != canonicalTargetID @@ -168,9 +169,7 @@ func installMany(keep func(Location) bool) (Result, error) { return res, nil } -// linkToCanonical symlinks target.Dir → canonical, with a SKILL.md -// copy fallback for hosts that can't symlink (unprivileged Windows). -// Assumes InstallCanonical already ran. +// linkToCanonical symlinks target.Dir → canonical; falls back to a file copy on hosts that can't symlink. func linkToCanonical(target Location) (LinkResult, error) { targetDir, err := Expand(target.Dir) if err != nil { @@ -186,8 +185,7 @@ func linkToCanonical(target Location) (LinkResult, error) { if err := os.MkdirAll(filepath.Dir(targetDir), 0o755); err != nil { return LinkResult{}, fmt.Errorf("create symlink parent: %w", err) } - // Re-install must converge: drop whatever's there (file, dir, or symlink). - _ = os.RemoveAll(targetDir) + _ = os.RemoveAll(targetDir) // drop whatever's there so re-install converges if err := os.Symlink(canonicalDir, targetDir); err == nil { return LinkResult{Target: target, Path: targetDir}, nil } else { @@ -207,7 +205,7 @@ func linkToCanonical(target Location) (LinkResult, error) { } } -// Expand resolves "~/" and returns an absolute path. +// Expand resolves a path that may start with ~ to an absolute path. func Expand(path string) (string, error) { if path == "~" { home, err := os.UserHomeDir() diff --git a/internal/ui/bar.go b/internal/ui/bar.go index 3d150b2..c61380b 100644 --- a/internal/ui/bar.go +++ b/internal/ui/bar.go @@ -11,25 +11,14 @@ import ( "github.com/charmbracelet/lipgloss" ) -// defaultBarWidth is the progress-bar fill width (number of cells) used -// as a fallback when the terminal size can't be measured. const defaultBarWidth = 50 - -// minBarWidth is the floor for the dynamic fit: even on absurdly narrow -// terminals we keep the bar drawable. const minBarWidth = 10 -// Bar is a two-line progress display: an animated spinner + status message on -// line 1, a solid-fill bar with a "processed/total" counter on line 2. -// -// Set/SetMessage are safe for concurrent use. Start/Stop should each be called -// at most once from the owning goroutine, though Stop is idempotent. +// Bar is a two-line progress display: spinner + message on line 1, solid-fill +// bar + counter on line 2. Set/SetMessage are safe for concurrent use. type Bar struct { - w io.Writer - // width, when > 0, locks the bar to a fixed cell count. When 0 - // (the production default) the bar fits to the terminal width each - // frame so it visually fills the screen — mirroring tools like mise. - width int + w io.Writer + width int // 0 = fit to terminal each frame; >0 = fixed width noTTY bool mu sync.Mutex @@ -37,12 +26,10 @@ type Bar struct { total int spinIdx int msg string - rendered bool // false until the first frame has been printed + rendered bool prog progress.Model - // Cached lipgloss styles. We gate on IsTTY before rendering, so these - // only emit ANSI on a real terminal regardless of lipgloss's own probe. spinnerStyle lipgloss.Style checkStyle lipgloss.Style counterStyle lipgloss.Style @@ -54,16 +41,12 @@ type Bar struct { stopOnce sync.Once } -// NewBar returns a Bar that writes to w. Pass width=0 (the production -// default) to fit the bar to the terminal width on every frame. Pass an -// explicit positive width to lock the bar to a fixed cell count — useful -// for tests where deterministic output matters. +// NewBar returns a Bar writing to w. width=0 fits the terminal each frame; +// a positive width locks to a fixed count (useful for deterministic tests). func NewBar(w io.Writer, width int) *Bar { if width > 0 && width < 4 { width = 4 } - // For dynamic (width=0) bars this is just a fallback before the first - // per-frame measurement. initialWidth := width if initialWidth == 0 { initialWidth = defaultBarWidth @@ -87,9 +70,7 @@ func NewBar(w io.Writer, width int) *Bar { } } -// Set updates the bar's current processed/total counts. Safe to call -// from any goroutine; the next animation tick (or the final frame -// written by Stop) reflects the new values. +// Set updates the processed and total counts used to compute bar progress. func (b *Bar) Set(processed, total int) { b.mu.Lock() b.processed = processed @@ -97,16 +78,14 @@ func (b *Bar) Set(processed, total int) { b.mu.Unlock() } -// SetMessage updates the status message shown on the spinner line. Safe -// to call from any goroutine. +// SetMessage updates the status text shown on the first line. func (b *Bar) SetMessage(msg string) { b.mu.Lock() b.msg = msg b.mu.Unlock() } -// Start begins the animation goroutine. On a non-TTY writer, Start is a -// no-op and Stop will likewise be silent. +// Start begins the animation loop. Idempotent. func (b *Bar) Start() { b.mu.Lock() if b.started { @@ -136,9 +115,7 @@ func (b *Bar) Start() { }() } -// Stop ends the animation and clears both bar lines, leaving the cursor at the -// column the bar started in so the caller's follow-up output (summary line, -// etc.) isn't duplicated. Idempotent and safe to call without a prior Start. +// Stop ends the animation and clears both bar lines. Idempotent. func (b *Bar) Stop() { b.mu.Lock() started := b.started @@ -153,15 +130,11 @@ func (b *Bar) Stop() { b.wg.Wait() if !rendered { - // Never drew a frame — nothing to erase. return } - // Clear line 2, move up one row, clear line 1, return to column 0. fmt.Fprint(b.w, "\r\x1b[2K\x1b[1F\x1b[2K") } -// renderTick reads state under the lock, advances the spinner index, -// and writes one frame. func (b *Bar) renderTick() { b.mu.Lock() processed, total, spinIdx, msg := b.processed, b.total, b.spinIdx, b.msg @@ -172,11 +145,6 @@ func (b *Bar) renderTick() { fmt.Fprint(b.w, b.frame(processed, total, spinIdx, msg, false, rendered)) } -// frame builds the two-line rendered output for the given state. -// -// On the very first frame (rendered=false) it prints both lines outright; -// subsequent frames first move the cursor back to the start of line 1 -// and clear each line before reprinting, so the bar updates in place. func (b *Bar) frame(processed, total, spinIdx int, msg string, done, rendered bool) string { pct := 0.0 if total > 0 { @@ -202,13 +170,11 @@ func (b *Bar) frame(processed, total, spinIdx int, msg string, done, rendered bo line1 := glyph + " " + b.msgStyle.Render(msg) - // Width of the largest count so the digits don't jitter as they grow. + // Right-align processed against total width so digits don't jitter as they grow. totalWidth := len(fmt.Sprintf("%d", total)) counter := b.counterStyle.Render(fmt.Sprintf("%*d/%d", totalWidth, processed, total)) - // Fill width: width==0 fits the terminal; width>0 is the desired width - // but still capped at terminal fit so it never wraps. Re-measured each - // frame so resizes are picked up without a signal handler. + // Re-measured each frame so terminal resizes are reflected without a signal handler. target := b.width if cols := terminalWidth(b.w); cols > 0 { fit := cols - lipgloss.Width(counter) - 2 @@ -231,8 +197,6 @@ func (b *Bar) frame(processed, total, spinIdx int, msg string, done, rendered bo buf.WriteString("\n") buf.WriteString(line2) } else { - // Move to the start of line 1 (\x1b[1F) and clear each line - // (\x1b[2K) before reprinting, so the bar updates in place. buf.WriteString("\x1b[1F\x1b[2K") buf.WriteString(line1) buf.WriteString("\n\x1b[2K") diff --git a/internal/ui/brand.go b/internal/ui/brand.go index 61366c4..96c4627 100644 --- a/internal/ui/brand.go +++ b/internal/ui/brand.go @@ -9,9 +9,7 @@ import ( "github.com/charmbracelet/lipgloss" ) -// brandArt is a braille-art rendering of the Emailable icon (the segmented -// ring + inner swirl), generated from emailable-icon.svg. Each rune packs a -// 2×4 dot cell; together with brandColors it's 16 rows × 36 cols. +// brandArt is the Emailable icon as braille art (16 rows × 36 cols, 2×4 dots/cell). const brandArt = "" + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣴⣶⣾⣿⣿⣿⣿⣿⣿⣷⣶⣦⣤⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n" + "⠀⠀⠀⠀⠀⠀⢀⣤⣶⣿⣿⣿⣿⣿⣿⣿⡿⠿⠿⠿⣿⣿⣿⣿⣿⣿⣿⣷⣤⡀⠀⠀⠀⠀⠀⠀\n" + @@ -30,9 +28,7 @@ const brandArt = "" + "⠀⠀⠀⠀⠀⠀⠈⠻⢿⣿⣿⣿⣿⣿⣿⣷⣶⣶⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n" + "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠛⠿⠿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀" -// brandColors maps each cell of brandArt to an Emailable brand color. Letters -// index brandPalette; '.' marks a blank (unlit) cell. Same dimensions as -// brandArt, aligned cell-for-cell. +// brandColors maps each brandArt cell to a palette letter; '.' = blank. const brandColors = "" + "..........KKKKKKKKPPPPPPPPP.........\n" + "......KKKKKKKKKKKKPPPPPPPPPPPP......\n" + @@ -51,25 +47,16 @@ const brandColors = "" + "......YYYYYYYYYYYY..................\n" + ".........YYYYYYYYY.................." -// brandName is the wordmark shown to the right of the mark. const brandName = "Emailable" +const brandNameLine = 7 // vertical midpoint of the mark +const blankBraille = '⠀' // U+2800, all-dots-off; keeps spacing constant -// brandNameLine is the row (0-indexed) the name is rendered beside — the -// vertical middle of the mark. -const brandNameLine = 7 - -// blankBraille is U+2800, an all-dots-off braille cell. Used as the glyph for -// unrevealed/unlit cells so spacing stays constant. -const blankBraille = '⠀' - -// BrandPurple is Emailable's primary brand purple, used for the wordmark text. +// BrandPurple is the primary Emailable brand color. var BrandPurple = lipgloss.Color("#7e61ff") -// BrandPurpleSoft is a lighter tint for supporting chrome (form gutters). +// BrandPurpleSoft is a lighter brand tint used for form gutters. var BrandPurpleSoft = lipgloss.Color("#c7c2ff") -// brandPalette maps the color letters in brandColors to their brand hex. -// '.' has no entry — it's never looked up because blank cells aren't styled. var brandPalette = map[byte]lipgloss.Color{ 'Y': lipgloss.Color("#ffcb60"), // yellow 'P': lipgloss.Color("#7e61ff"), // purple @@ -80,10 +67,8 @@ var brandPalette = map[byte]lipgloss.Color{ 'O': lipgloss.Color("#ff9c5b"), // orange } -// brandGrid is the parsed, render-ready form of the embedded art. The color -// grid is canonical for width and lit-ness ('.' == blank); glyph rows are -// padded with blankBraille to match, so a lost trailing blank in the literal -// can't desync the two grids. +// brandGrid is the parsed art. Color grid is canonical; glyph rows are padded +// with blankBraille so trailing-blank loss in the literal can't desync them. type brandGrid struct { glyphs [][]rune colors [][]byte @@ -95,7 +80,6 @@ var ( parsedBrand brandGrid ) -// grid parses brandArt/brandColors once and caches the result. func grid() brandGrid { brandOnce.Do(func() { colorLines := strings.Split(brandColors, "\n") @@ -109,8 +93,6 @@ func grid() brandGrid { for r := range colorLines { cr := []byte(colorLines[r]) gr := []rune(glyphLines[r]) - // Pad the glyph row to the color row's width; trailing blanks may - // have been dropped from the literal. for len(gr) < len(cr) { gr = append(gr, blankBraille) } @@ -122,7 +104,6 @@ func grid() brandGrid { return parsedBrand } -// paletteStyles is brandPalette pre-wrapped as lipgloss styles, built once. var paletteStyles = func() map[byte]lipgloss.Style { m := make(map[byte]lipgloss.Style, len(brandPalette)) for k, c := range brandPalette { @@ -131,8 +112,6 @@ var paletteStyles = func() map[byte]lipgloss.Style { return m }() -// renderBrandStatic prints the mark once with no color and no animation, with -// the name beside it. Used when w isn't a color TTY (piped output, NO_COLOR). func renderBrandStatic(w io.Writer) { g := grid() for r := 0; r < g.rows; r++ { diff --git a/internal/ui/brand_anim.go b/internal/ui/brand_anim.go index 95600fc..dd5a8f1 100644 --- a/internal/ui/brand_anim.go +++ b/internal/ui/brand_anim.go @@ -10,28 +10,16 @@ import ( "github.com/charmbracelet/lipgloss" ) -// Animation cadence. Cells are revealed over several frames, then the name -// types in letter by letter; the whole sequence runs in well under 1.5s. const ( brandSweepInterval = 22 * time.Millisecond brandTextInterval = 45 * time.Millisecond brandRevealFrames = 28 // target frames for the reveal phase ) -// cell is a position in the brand grid. type cell struct{ row, col int } -// AnimateBrand paints the Emailable mark onto w by tracing its swirl like a -// pen, then reveals the "Emailable" wordmark beside it. It blocks until the -// animation finishes, leaving the fully-rendered mark on screen. When w isn't a -// color TTY (piped output or NO_COLOR) it renders once, statically and -// uncolored. -// -// Rendering is incremental to avoid flicker: it lays down an empty block, then -// each frame paints only the cells revealed that frame, in place. Already-lit -// cells are never repainted — repainting stable cells (and the cursor snapping -// to column 0) every frame is what flickers. The cursor is hidden for the same -// reason. Nothing else must write to w until it returns. +// AnimateBrand traces the brand mark onto w cell-by-cell, then types the +// wordmark. Blocks until done; nothing else must write to w until it returns. func AnimateBrand(w io.Writer) { if !IsTTY(w) { renderBrandStatic(w) @@ -52,14 +40,12 @@ func AnimateBrand(w io.Writer) { fmt.Fprint(w, "\033[?25l") // hide the cursor for the duration defer fmt.Fprint(w, "\033[?25h") - // Lay down an empty, cleared block for paintCells to cursor-up over (so no - // stale terminal text shows through the mark's blank cells). The \r before - // each clear guarantees a full-line wipe even if the cursor wasn't at col 0. + // \r before each clear guarantees a full-line wipe even if the cursor + // wasn't at col 0. for r := 0; r < g.rows; r++ { fmt.Fprint(w, "\r\033[K\n") } - // Reveal one batch of cells per frame, painting only that batch. for f := 0; f < numBatches; f++ { items := map[int][]glyphAt{} for _, c := range order[f*batch : min((f+1)*batch, len(order))] { @@ -75,7 +61,6 @@ func AnimateBrand(w io.Writer) { time.Sleep(brandSweepInterval) } - // Reveal the wordmark one letter at a time, beside the middle row. nameCol := len(g.colors[brandNameLine]) + 3 for i := 0; i < len(brandName); i++ { paintCells(w, g.rows, map[int][]glyphAt{ @@ -85,18 +70,14 @@ func AnimateBrand(w io.Writer) { } } -// glyphAt is a single styled glyph to paint at a column within a row. type glyphAt struct { col int s string } -// paintCells moves to the top of the rows-tall block, paints each given glyph -// at its (row, col) in place — touching nothing else — and returns the cursor -// just below the block. items[r] must be sorted by col. Painting only changed -// cells (rather than repainting whole lines) is what keeps stable cells from -// flickering. Each \n is followed by \r so column 0 is reached regardless of -// the terminal's newline translation. +// paintCells paints only the given cells in place (cursor-up, then per-cell +// positioning). Repainting whole lines would flicker stable cells. +// items[r] must be sorted by col. func paintCells(w io.Writer, rows int, items map[int][]glyphAt) { fmt.Fprintf(w, "\033[%dA", rows) // up to the first row of the block for r := 0; r < rows; r++ { @@ -116,15 +97,12 @@ func paintCells(w io.Writer, rows int, items map[int][]glyphAt) { fmt.Fprint(w, "\n\r") // step below the block, back to column 0 } -// The stroke is traced as two hand-authored polylines of (col, row) waypoints -// in grid space (cols 0–35, rows 0–15): the outer ring, then the inner swirl. -// Splitting them lets us match each cell to its own band's path (see -// traceOrder), so a swirl cell can't be grabbed by a nearby ring segment. +// Two hand-authored polylines in grid space (cols 0–35, rows 0–15). Splitting +// ring and swirl lets each cell match only its own band; without the split, +// nearby swirl cells would be grabbed by ring segments. var ( - // brandRingPath traces the outer ring clockwise: bottom-center, up the - // left, over the top, down the right, to the ring's open lower-right. brandRingPath = []point{ - {16, 15}, // start: bottom-center + {16, 15}, {10, 14}, {5, 13}, {2, 9}, @@ -136,15 +114,12 @@ var ( {27, 2}, {32, 5}, {34, 8}, - {32, 10}, // ring's open lower-right end + {32, 10}, } - // brandSwirlPath traces the inner swirl: in at the lower-right tail, then - // counterclockwise around and in. It ends at the bottom-right inner, not the - // hollow center — a center endpoint pulls the center-top cells past it so - // they light last. Early waypoints stay in the teal band (col ≥ 26) so they - // don't grab inner cells. + // Ends at the bottom-right inner rather than the hollow center — a center + // endpoint pulls center-top cells past it so they light last. brandSwirlPath = []point{ - {32, 11}, // start: tail tip, entering from the ring + {32, 11}, {29, 11}, {26, 10}, {25, 8}, @@ -155,28 +130,18 @@ var ( {10, 8}, {11, 11}, {15, 12}, - {20, 11}, // bottom-right inner end + {20, 11}, } ) -// point is a waypoint on a brand stroke polyline, in grid coordinates. type point struct{ col, row float64 } -// pathSamplesPerSeg is how finely each polyline segment is sampled when -// matching cells to their nearest point on the stroke. const pathSamplesPerSeg = 24 -// brandRowAspect weights the row axis when measuring distances: braille cells -// are about twice as tall as wide on screen, so without it the nearest-point +// Braille cells are ~2× taller than wide; without this the nearest-point // match would be skewed vertically. const brandRowAspect = 2.0 -// traceOrder reveals cells by tracing the stroke like a pen. Ring cells (the -// pink/purple/orange/yellow band) are matched to brandRingPath and swirl cells -// (teal) to brandSwirlPath; within each, a cell takes the arc-length position -// of its nearest point on that path. The ring is revealed first, then the -// swirl — so each frame lights only the few cells the pen tip is passing, and -// the two bands never bleed into each other's phase. func traceOrder(g brandGrid) []cell { ring := samplePath(brandRingPath) swirl := samplePath(brandSwirlPath) @@ -217,12 +182,8 @@ func traceOrder(g brandGrid) []cell { return out } -// isSwirlColor reports whether a color letter belongs to the inner swirl (the -// teal band) rather than the outer ring. func isSwirlColor(c byte) bool { return c == 'T' || c == 'L' } -// samplePath returns evenly-spaced points along the polyline, with the row axis -// scaled by brandRowAspect so distances match what the eye sees. func samplePath(path []point) [][2]float64 { var pts [][2]float64 for seg := 0; seg+1 < len(path); seg++ { @@ -237,7 +198,6 @@ func samplePath(path []point) [][2]float64 { return pts } -// nearestSample returns the index of the path sample closest to (x, y). func nearestSample(pts [][2]float64, x, y float64) int { best, bestD := 0, math.MaxFloat64 for i, p := range pts { diff --git a/internal/ui/confirm.go b/internal/ui/confirm.go index 1863dc4..f00b011 100644 --- a/internal/ui/confirm.go +++ b/internal/ui/confirm.go @@ -7,7 +7,7 @@ import ( "github.com/charmbracelet/huh" ) -// Confirm returns true only on Yes; No / esc / ctrl-c collapse to false. +// Confirm prompts for a yes/no answer, returning false if the user aborts. func Confirm(in io.Reader, out io.Writer, message string) (yes bool, err error) { var v bool field := huh.NewConfirm(). diff --git a/internal/ui/prompt.go b/internal/ui/prompt.go index 11ec05f..f0f0de8 100644 --- a/internal/ui/prompt.go +++ b/internal/ui/prompt.go @@ -8,13 +8,12 @@ import ( "github.com/charmbracelet/huh" ) -// Prompt returns value, ok, err. ok is false on esc / ctrl-c. mask -// echoes bullets for secrets. +// Prompt returns value, ok, err; ok is false on esc/ctrl-c. func Prompt(in io.Reader, out io.Writer, label string, mask bool) (value string, ok bool, err error) { return PromptWithPlaceholder(in, out, label, "", mask) } -// PromptWithPlaceholder is Prompt with a placeholder hint. +// PromptWithPlaceholder is Prompt with a placeholder shown in the empty input. func PromptWithPlaceholder(in io.Reader, out io.Writer, label, placeholder string, mask bool) (value string, ok bool, err error) { var v string field := huh.NewInput(). diff --git a/internal/ui/select.go b/internal/ui/select.go index 6ab32b9..fab519c 100644 --- a/internal/ui/select.go +++ b/internal/ui/select.go @@ -8,13 +8,13 @@ import ( "github.com/charmbracelet/lipgloss" ) -// Choice is one selectable item. Hint renders dim and inline. +// Choice is a selectable option with an optional dimmed hint. type Choice struct { Label string Hint string } -// Select returns idx, ok, err. ok is false on esc / ctrl-c. +// Select prompts the user to pick one of choices; ok is false on esc/ctrl-c. func Select(in io.Reader, out io.Writer, prompt string, choices []Choice) (idx int, ok bool, err error) { if len(choices) == 0 { return 0, false, errors.New("ui.Select: no choices") diff --git a/internal/ui/spinner.go b/internal/ui/spinner.go index 0000b7e..2730f1c 100644 --- a/internal/ui/spinner.go +++ b/internal/ui/spinner.go @@ -1,5 +1,4 @@ -// Package ui holds shared terminal-UI primitives (spinner, progress bar) so -// every animated wait in the CLI uses the same cadence and styling. +// Package ui holds shared terminal-UI primitives for animated CLI output. package ui import ( @@ -13,25 +12,19 @@ import ( "golang.org/x/term" ) -// noColorEnv is the env var name from https://no-color.org/. Any non-empty -// value suppresses ANSI styling even on a real TTY. +// noColorEnv — see https://no-color.org/ const noColorEnv = "NO_COLOR" -// SpinnerStyle is the shared style for the spinner glyph so it reads the same -// everywhere; changing the color here changes every spinner at once. +// SpinnerStyle is the shared style for the spinner glyph. var SpinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) -// Frames is the Braille spinner used for every animated wait in the CLI. +// Frames are the spinner's animation frames. var Frames = []rune("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏") -// TickInterval is the redraw cadence (~10 fps). +// TickInterval is the spinner's redraw cadence. const TickInterval = 100 * time.Millisecond -// IsTTY reports whether w writes to a terminal AND color/animation should be -// enabled, gating ANSI output so pipes don't fill with control codes. -// -// Honors the NO_COLOR convention (https://no-color.org/): a non-empty NO_COLOR -// env var returns false even on a real terminal. +// IsTTY reports whether ANSI output is appropriate for w (real TTY + NO_COLOR not set). func IsTTY(w io.Writer) bool { if os.Getenv(noColorEnv) != "" { return false @@ -39,8 +32,7 @@ func IsTTY(w io.Writer) bool { return isTerminal(w) } -// isTerminal is the pure file-descriptor check. A var so tests can swap in a -// fake TTY. +// isTerminal is a var so tests can swap in a fake TTY. var isTerminal = func(w io.Writer) bool { f, ok := w.(*os.File) if !ok { @@ -49,15 +41,11 @@ var isTerminal = func(w io.Writer) bool { return term.IsTerminal(int(f.Fd())) } -// IsTerminal reports whether w is a terminal, ignoring NO_COLOR. Use it for -// interactivity decisions; IsTTY (which honors NO_COLOR) is for styling. +// IsTerminal checks the fd only, ignoring NO_COLOR. Use for interactivity; use IsTTY for styling. func IsTerminal(w io.Writer) bool { return isTerminal(w) } -// terminalWidth returns the column count of the terminal w is writing to, -// or 0 if w isn't a TTY or the size can't be determined. Re-measured on -// every frame so the progress bar tracks terminal resizes. func terminalWidth(w io.Writer) int { f, ok := w.(*os.File) if !ok { @@ -70,11 +58,10 @@ func terminalWidth(w io.Writer) int { return cols } -// Spinner is a single-line animated status indicator. It writes to stderr by -// default and degrades to a single status print when stderr is not a TTY. +// Spinner is a single-line animated status indicator; degrades to a single print on non-TTY. type Spinner struct { w io.Writer - noP bool // true => not a TTY; suppress animation, fall back to a single print + noP bool mu sync.Mutex msg string @@ -85,12 +72,12 @@ type Spinner struct { stopOnce sync.Once } -// New returns a Spinner that writes to stderr with the given initial message. +// New returns a Spinner that writes to stderr. func New(message string) *Spinner { return NewTo(os.Stderr, message) } -// NewTo returns a Spinner that writes to w. Used by tests that want a buffer. +// NewTo returns a Spinner that writes to w. func NewTo(w io.Writer, message string) *Spinner { s := &Spinner{ w: w, @@ -101,16 +88,14 @@ func NewTo(w io.Writer, message string) *Spinner { return s } -// SetMessage updates the message rendered next to the spinner. Safe from any -// goroutine. +// SetMessage updates the message shown next to the spinner. func (s *Spinner) SetMessage(msg string) { s.mu.Lock() s.msg = msg s.mu.Unlock() } -// Start begins the animation. When the writer is not a TTY, prints the -// message once and returns; Stop is then a no-op. +// Start begins the spinner animation. func (s *Spinner) Start() { s.mu.Lock() if s.started { @@ -138,8 +123,7 @@ func (s *Spinner) Start() { s.mu.Lock() m := s.msg s.mu.Unlock() - // \r + \033[K clears the line so a shorter message doesn't - // leave stale characters from a longer previous one. + // \r\033[K clears the line so a shorter message doesn't leave stale characters. glyph := SpinnerStyle.Render(string(Frames[i%len(Frames)])) fmt.Fprintf(s.w, "\r\033[K%s %s", glyph, m) i++ @@ -150,7 +134,7 @@ func (s *Spinner) Start() { }() } -// Stop ends the animation and clears the spinner line. Idempotent. +// Stop ends the animation and clears the spinner line. func (s *Spinner) Stop() { s.mu.Lock() started := s.started diff --git a/internal/ui/style.go b/internal/ui/style.go index 8b61cfb..f2e638e 100644 --- a/internal/ui/style.go +++ b/internal/ui/style.go @@ -1,7 +1,6 @@ package ui -// ANSI styling helpers. Each takes an explicit tty bool so the caller detects -// TTY-ness once and propagates it; when false the helpers return s unchanged. +// ANSI helpers take an explicit tty bool; when false they return s unchanged. const ( ansiReset = "\033[0m" @@ -10,7 +9,7 @@ const ( ansiCyan = "\033[36m" ) -// Cyan wraps s in ANSI cyan codes when tty is true, otherwise returns s as-is. +// Cyan styles s cyan when tty is true. func Cyan(s string, tty bool) string { if !tty { return s @@ -18,7 +17,7 @@ func Cyan(s string, tty bool) string { return ansiCyan + s + ansiReset } -// Dim wraps s in ANSI dim codes when tty is true, otherwise returns s as-is. +// Dim styles s dim when tty is true. func Dim(s string, tty bool) string { if !tty { return s @@ -26,8 +25,7 @@ func Dim(s string, tty bool) string { return ansiDim + s + ansiReset } -// Heading renders a section heading: bold + cyan when tty, plain otherwise. -// Used for the uppercase section labels in help output (USAGE, FLAGS, etc.). +// Heading styles s as a bold cyan heading when tty is true. func Heading(s string, tty bool) string { if !tty { return s diff --git a/internal/ui/theme.go b/internal/ui/theme.go index c0e649e..c8cbd88 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -8,8 +8,7 @@ import ( var dimColor = lipgloss.Color("241") -// EmailableTheme overrides huh.ThemeBase with brand colors and our -// "❯ " select cursor. +// EmailableTheme returns the huh form theme using the Emailable brand colors. func EmailableTheme() *huh.Theme { t := huh.ThemeBase() @@ -59,7 +58,7 @@ func EmailableTheme() *huh.Theme { return t } -// EscKeyMap adds esc to huh's default ctrl+c quit binding. +// EscKeyMap returns a huh key map that quits on esc or ctrl+c. func EscKeyMap() *huh.KeyMap { km := huh.NewDefaultKeyMap() km.Quit = key.NewBinding(key.WithKeys("esc", "ctrl+c")) diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 55419ce..143adbb 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -1,11 +1,4 @@ // Package updater implements an unobtrusive "new release available" notifier. -// It checks GitHub for the latest release at most once per 24h, caches the -// answer on disk, and prints a short two-line stderr notice (version + release -// URL) when an update exists. -// -// The notifier must never block or fail the user's command: the check runs in -// a goroutine, the caller waits at most ~1s for it before exiting, and any -// error is swallowed silently. All I/O is injectable for hermetic tests. package updater import ( @@ -21,49 +14,35 @@ import ( "time" ) -// ReleasesAPIURL is the GitHub Releases endpoint we poll. Exposed as a var so -// tests can point it at an httptest.Server. +// ReleasesAPIURL is the GitHub Releases endpoint we poll; a var so tests can redirect it. var ReleasesAPIURL = "https://api.github.com/repos/emailable/emailable-cli/releases/latest" -// ReleasesPageURL is the human-facing URL printed in the notice. Tests don't -// override this — it just gets echoed into the notice string verbatim. +// ReleasesPageURL is the human-facing GitHub Releases page linked in update notices. const ReleasesPageURL = "https://github.com/emailable/emailable-cli/releases/latest" -// CacheTTL is how long a cache hit suppresses the network call. 24h so users -// see updates roughly daily without a check every run. +// CacheTTL is how long a cached update check is reused before a fresh fetch is made. const CacheTTL = 24 * time.Hour -// HTTPTimeout caps every GitHub API request, short enough that a hung -// connection won't materially delay process exit. +// HTTPTimeout is the deadline for a single update-check HTTP request. const HTTPTimeout = 5 * time.Second // Result is the outcome of a Check. An empty LatestVersion means "nothing // useful to say" (silent failure, ambiguous comparison, or already current). type Result struct { - // CurrentVersion is the caller's version, leading "v" stripped. - CurrentVersion string - // LatestVersion is the latest release tag, leading "v" stripped. Empty - // when there's nothing to report. - LatestVersion string - // UpdateAvailable is true iff LatestVersion > CurrentVersion. False when - // versions match, either side fails to parse, or no fetch was attempted. + CurrentVersion string + LatestVersion string UpdateAvailable bool } -// cacheEntry is the on-disk schema: timestamp + last-seen version. type cacheEntry struct { CheckedAt time.Time `json:"checked_at"` LatestVersion string `json:"latest_version"` } -// httpClient is the package-level HTTP client. Exposed as a var so tests can -// swap it out for one pointed at an httptest server with no real network. +// httpClient is a var so tests can swap it for one pointed at an httptest server. var httpClient = &http.Client{Timeout: HTTPTimeout} -// Check returns the latest release info, using and updating a 24h disk cache -// under cacheDir. currentVersion is the running binary's version (leading "v" -// optional). Any error yields a zero Result: the notifier is best-effort and -// must never fail the caller. ctx bounds the whole operation. +// Check returns the update-check Result for currentVersion, using cacheDir to cache the last fetch. func Check(ctx context.Context, currentVersion, cacheDir string) Result { if currentVersion == "" || currentVersion == "dev" { return Result{} @@ -76,29 +55,21 @@ func Check(ctx context.Context, currentVersion, cacheDir string) Result { cachePath := filepath.Join(cacheDir, "update-check.json") - // Fresh cache hit: still compare so a known upgrade nags every run until - // the user upgrades. + // Still compare on a cache hit so a known upgrade nags every run until the user upgrades. if entry, ok := readCache(cachePath); ok && time.Since(entry.CheckedAt) < CacheTTL { return buildResult(cur, entry.LatestVersion) } latest, ok := fetchLatest(ctx) if !ok { - // Preserve any stale entry on failure so an offline run doesn't lose - // the previously known version. return Result{} } - // Best-effort cache write; a failure just means we re-fetch next time. _ = writeCache(cachePath, cacheEntry{CheckedAt: time.Now().UTC(), LatestVersion: latest}) return buildResult(cur, latest) } -// buildResult assembles a Result from normalized version strings. Returns a -// zero Result when latest is empty or either side fails semver parse. Equal -// versions populate the fields with UpdateAvailable=false, distinguishing -// "checked, up to date" from "nothing to report". func buildResult(current, latest string) Result { latest = normalizeVersion(latest) if latest == "" { @@ -115,11 +86,7 @@ func buildResult(current, latest string) Result { } } -// fetchLatest GETs the releases endpoint and returns the tag_name, leading -// "v" stripped. Returns ok=false on any error so callers can skip uniformly. func fetchLatest(ctx context.Context) (string, bool) { - // Internal timeout on top of ctx so a context.Background() caller still - // gets bounded I/O. rctx, cancel := context.WithTimeout(ctx, HTTPTimeout) defer cancel() @@ -144,7 +111,7 @@ func fetchLatest(ctx context.Context) (string, bool) { var payload struct { TagName string `json:"tag_name"` } - // Cap at 1 MiB so a runaway response can't soak memory. + // 1 MiB cap so a runaway response can't soak memory. if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil { return "", false } @@ -155,8 +122,6 @@ func fetchLatest(ctx context.Context) (string, bool) { return normalizeVersion(tag), true } -// readCache loads the cache file at path. Returns ok=false on any error so -// the caller falls through to a fresh fetch. func readCache(path string) (cacheEntry, bool) { b, err := os.ReadFile(path) if err != nil { @@ -166,15 +131,12 @@ func readCache(path string) (cacheEntry, bool) { if err := json.Unmarshal(b, &e); err != nil { return cacheEntry{}, false } - // Treat a wholly empty entry as a miss so it can't mask a fetch error. if e.CheckedAt.IsZero() && e.LatestVersion == "" { return cacheEntry{}, false } return e, true } -// writeCache persists e to path, creating the parent directory if needed. -// Returns the error for callers that want it; production callers ignore it. func writeCache(path string, e cacheEntry) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err @@ -183,51 +145,41 @@ func writeCache(path string, e cacheEntry) error { if err != nil { return err } - // 0600: nothing here is secret, but tighter perms cost nothing. return os.WriteFile(path, b, 0o600) } -// SkipReason represents why the notifier opted out on this invocation. +// SkipReason describes why ShouldSkip decided to suppress the update check. type SkipReason int const ( - // SkipNone means no skip condition matched; the notifier should run. + // SkipNone means the update check should proceed. SkipNone SkipReason = iota - // SkipDevVersion is set for the "dev" version (don't nag local checkouts). + // SkipDevVersion skips when running a dev or unversioned build. SkipDevVersion - // SkipOptOut is set when the opt-out env var is truthy. + // SkipOptOut skips when the user has opted out. SkipOptOut - // SkipCI is set when the CI env var is non-empty. + // SkipCI skips when the CI environment variable is set. SkipCI - // SkipJSON is set in --json mode (no stderr line in machine output). + // SkipJSON skips in JSON output mode to avoid polluting machine output. SkipJSON - // SkipQuiet is set when --quiet is active. + // SkipQuiet skips when quiet mode is active. SkipQuiet - // SkipNonTTY is set when stderr isn't a terminal. + // SkipNonTTY skips when stderr is not a terminal. SkipNonTTY ) -// Conditions is the set of runtime knobs ShouldSkip inspects. All sources are -// passed in (not read from globals) so tests can drive every branch. +// Conditions is the set of runtime knobs ShouldSkip inspects. All are passed +// in rather than read from globals so tests can drive every branch. type Conditions struct { - // CurrentVersion is the running binary's version string. CurrentVersion string - // JSONMode is true when output is in JSON mode. - JSONMode bool - // Quiet is true when --quiet/-q suppresses the notice. - Quiet bool - // StderrTTY is true when stderr is a terminal; false suppresses the notice. - StderrTTY bool - // OptOut is the resolved opt-out signal, computed by the caller so this - // package doesn't read the environment for it. - OptOut bool - // Env reads environment variables; injectable for tests. nil means - // os.Getenv. Currently only the CI check uses it. - Env func(string) string + JSONMode bool + Quiet bool + StderrTTY bool + OptOut bool + Env func(string) string // nil => os.Getenv } -// ShouldSkip returns the first matching skip reason, or SkipNone if all -// skip conditions are false (i.e. the notifier should proceed). +// ShouldSkip returns the first SkipReason that applies to c, or SkipNone. func ShouldSkip(c Conditions) SkipReason { getenv := c.Env if getenv == nil { @@ -254,9 +206,6 @@ func ShouldSkip(c Conditions) SkipReason { return SkipNone } -// isTruthy returns true for "1", "true", "yes", "on" (case-insensitive). -// Empty / "0" / "false" return false. Keeps the opt-out env var behaviour -// predictable for users who type the obvious values. func isTruthy(v string) bool { switch strings.ToLower(strings.TrimSpace(v)) { case "1", "true", "yes", "on": @@ -265,15 +214,11 @@ func isTruthy(v string) bool { return false } -// FormatNotice renders the dim two-line notice. When tty is false the output -// is plain text. Returns empty string when no notice should be printed (no -// update, missing versions, etc.) so callers can unconditionally print the -// return value. +// FormatNotice returns a human-readable update notice for r, or an empty string if no update is available. func FormatNotice(r Result, tty bool) string { if !r.UpdateAvailable || r.CurrentVersion == "" || r.LatestVersion == "" { return "" } - // Leading blank line separates the notice from the command's output. line1 := fmt.Sprintf("A new release of emailable is available: %s → %s", r.CurrentVersion, r.LatestVersion) line2 := ReleasesPageURL if !tty { @@ -284,9 +229,7 @@ func FormatNotice(r Result, tty bool) string { return "\n" + dim + line1 + reset + "\n" + dim + line2 + reset + "\n" } -// MaybeNotify is the convenience top-level entry point: combine a Result and -// a writer, and print the notice (if any) honoring TTY/color detection. Only -// writes to w when there's something to say. Returns nil on a no-op. +// MaybeNotify writes a formatted update notice to w if r indicates an available update. func MaybeNotify(w io.Writer, r Result, tty bool) error { notice := FormatNotice(r, tty) if notice == "" { @@ -296,9 +239,7 @@ func MaybeNotify(w io.Writer, r Result, tty bool) error { return err } -// CacheDir returns the update-check cache directory, honoring $XDG_CACHE_HOME -// and falling back to ~/.cache. Returns "" if neither resolves (the caller -// should then skip the cache). +// CacheDir returns the platform-appropriate cache directory for update-check state. func CacheDir() string { if x := os.Getenv("XDG_CACHE_HOME"); x != "" { return filepath.Join(x, "emailable") @@ -310,22 +251,13 @@ func CacheDir() string { return filepath.Join(home, ".cache", "emailable") } -// --- Semver comparator ------------------------------------------------------- -// -// Hand-rolled to avoid pulling in golang.org/x/mod/semver. It only needs the -// canonical X.Y.Z[-pre][+build] form GitHub release tags use, covering the -// one comparison we do: "is latest > current?" - -// semver is a parsed version. Pre is the dot-separated pre-release portion -// (without the leading "-"); empty means a stable release, which by semver -// rules sorts after any same-MMP pre-release. +// semver is a parsed version. Pre is empty for stable releases, which sort +// after any same-MMP pre-release per semver §11.4. type semver struct { Major, Minor, Patch int Pre string } -// normalizeVersion strips a leading "v" and any whitespace. Empty input -// returns empty. func normalizeVersion(v string) string { v = strings.TrimSpace(v) if strings.HasPrefix(v, "v") || strings.HasPrefix(v, "V") { @@ -334,15 +266,13 @@ func normalizeVersion(v string) string { return v } -// parseSemver parses "MAJOR.MINOR.PATCH[-pre][+build]". Returns ok=false on -// any malformed input. Build metadata is discarded (it doesn't affect -// precedence per semver §10). +// parseSemver parses "MAJOR.MINOR.PATCH[-pre][+build]"; build metadata is +// discarded per semver §10 (doesn't affect precedence). func parseSemver(v string) (semver, bool) { v = normalizeVersion(v) if v == "" { return semver{}, false } - // Strip build metadata. if i := strings.Index(v, "+"); i >= 0 { v = v[:i] } @@ -370,9 +300,6 @@ func parseSemver(v string) (semver, bool) { return semver{Major: maj, Minor: min, Patch: pat, Pre: pre}, true } -// compareSemver returns -1/0/1 for a < b / a == b / a > b. ok=false when -// either side fails to parse — the caller then suppresses the notice -// rather than guess. func compareSemver(a, b string) (int, bool) { sa, oka := parseSemver(a) sb, okb := parseSemver(b) @@ -388,9 +315,7 @@ func compareSemver(a, b string) (int, bool) { if c := cmpInt(sa.Patch, sb.Patch); c != 0 { return c, true } - // Same MAJOR.MINOR.PATCH: pre-release rules (semver §11.4): - // - a stable release > any pre-release of same MMP - // - pre-release identifiers compared lexically segment-by-segment + // semver §11.4: stable release sorts after any same-MMP pre-release. switch { case sa.Pre == "" && sb.Pre == "": return 0, true @@ -402,9 +327,6 @@ func compareSemver(a, b string) (int, bool) { return cmpPreRelease(sa.Pre, sb.Pre), true } -// cmpPreRelease compares two non-empty pre-release strings. Numeric -// identifiers compare numerically; alphanumeric compare lexically; numeric -// identifiers have lower precedence than alphanumeric (semver §11.4). func cmpPreRelease(a, b string) int { ap := strings.Split(a, ".") bp := strings.Split(b, ".") @@ -419,8 +341,7 @@ func cmpPreRelease(a, b string) int { return c } case aNum: - // Numeric identifiers have lower precedence than alphanumeric. - return -1 + return -1 // semver §11.4: numeric < alphanumeric case bNum: return 1 default: @@ -435,7 +356,7 @@ func cmpPreRelease(a, b string) int { return cmpInt(len(ap), len(bp)) } -// cmpInt is a -1/0/1 comparator over ints (subtract-and-sign risks overflow). +// cmpInt returns -1/0/1; subtraction-and-sign risks overflow on large ints. func cmpInt(a, b int) int { switch { case a < b: diff --git a/skills/embed.go b/skills/embed.go index e54d578..9e210db 100644 --- a/skills/embed.go +++ b/skills/embed.go @@ -3,5 +3,7 @@ package skills import "embed" +// FS holds the embedded skill tree. +// //go:embed emailable var FS embed.FS From 1de09c1479e4653731b64275006bea99786e1acb Mon Sep 17 00:00:00 2001 From: Jarrett Lusso Date: Mon, 1 Jun 2026 19:06:00 -0400 Subject: [PATCH 2/2] Address review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the duplicated PrintVerifyResult doc comment into one block, and reapply the comment trims that didn't survive the rebase onto master — the conflict resolution had kept master's verbose comments where the comment branch overlapped master's retry/auth refactors (waitForCompletion, requireAuth, renderError, do, backoffFor, parseRateLimit, the retry-knob consts, and the RateLimit type), so restore the terse versions while keeping master's code. The empty // line above the go:embed directive in skills/embed.go is left as-is: gofmt requires that separator between a doc comment and a //go: directive. --- cmd/batch.go | 7 +------ cmd/context.go | 10 ---------- cmd/errors.go | 17 ----------------- cmd/root.go | 8 ++------ internal/api/client.go | 18 ------------------ internal/api/errors.go | 3 +-- internal/output/human.go | 6 +++--- 7 files changed, 7 insertions(+), 62 deletions(-) diff --git a/cmd/batch.go b/cmd/batch.go index cf90721..b44f38f 100644 --- a/cmd/batch.go +++ b/cmd/batch.go @@ -301,12 +301,7 @@ const ( fastPollWindow = 10 * time.Second ) -// waitForCompletion polls the batch status until processing is complete and -// returns the final status. In non-JSON mode it renders a progress bar; when sw -// is non-nil it emits `progress` events instead and suppresses the bar. -// -// Progress output goes to stderr so piping stdout (e.g. `verify --wait > -// results.json`) doesn't mix the bar into the result payload. +// waitForCompletion polls until completion. Progress goes to stderr so piped stdout stays clean. func waitForCompletion(ctx context.Context, client *api.Client, id string, jsonMode bool, sw *batchStreamer, progressOut io.Writer) (*api.BatchStatus, error) { if progressOut == nil { progressOut = os.Stderr diff --git a/cmd/context.go b/cmd/context.go index 3695941..d464dcb 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -105,16 +105,6 @@ func (c *cmdCtx) withRefreshNotice(w io.Writer) *cmdCtx { return c } -// requireAuth returns an *api.Client configured for the active environment. -// Resolution order: -// 1. EMAILABLE_API_KEY / stored API key — non-interactive auth; no refresh -// path. -// 2. Stored OAuth access token — refreshed transparently when close to -// expiry. A failed refresh caused by a permanently-dead refresh token -// (oauth.ErrInvalidGrant) collapses to errNotAuthenticated so the user -// is prompted to log in again; other failures propagate verbatim. -// 3. errNotAuthenticated — the user must `emailable login` or set an -// API key. func (c *cmdCtx) requireAuth(ctx context.Context) (*api.Client, error) { if key, _ := c.effectiveAPIKey(); key != "" { return api.NewWithOptions(c.Env.APIBaseURL, key, c.clientOptions()), nil diff --git a/cmd/errors.go b/cmd/errors.go index f401503..d65af6c 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -127,23 +127,6 @@ func isNetworkError(err error) bool { return errors.As(err, &netErr) } -// renderError writes a terminal-friendly representation of err to w. -// -// JSON mode: emits a single line of JSON to stderr, always flat (no envelope). -// For *api.Error with a JSON-object body it passes the body through verbatim, -// merging in a `rate_limit` field when rate-limit headers were captured. For -// non-object / non-JSON bodies it synthesizes a flat object: -// -// {"message":"...","status_code":N,"code":"..."} -// -// A stable `code` field is always added (preserving any code the API -// returned). For non-API errors (network, config, validation) it emits a -// flat object with `message` + `code`. The shape is intentionally consistent -// across paths so agents can parse a single schema. -// -// Human mode: prints `Error: (HTTP )` for *api.Error and -// `Error: ` otherwise. On 429 with a known reset timestamp we append a -// retry hint so the user knows when to retry. func renderError(w io.Writer, err error, jsonMode bool) { if err == nil { return diff --git a/cmd/root.go b/cmd/root.go index 350905b..f913ceb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -33,8 +33,6 @@ const releaseURLPrefix = "https://github.com/emailable/emailable-cli/releases/ta // pseudo-version builds would 404. var releaseVersion = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) -// jsonOutput is the value of the persistent --json flag. Commands read this -// (rather than re-querying the cobra flag set) to pick an output formatter. var jsonOutput bool var ( @@ -180,10 +178,8 @@ const longDescription = "Command-line interface for Emailable's email verificati func newRootCmd(v string) *cobra.Command { resetRootFlagState() - // Swap the package-level version so versionDisplay and the version - // subcommand's lazily-read RunE observe v. Intentionally not restored: - // tests rely on the swap persisting across Execute and don't run - // newRootCmd in parallel, so leaving it set is safe. + // Intentionally not restored: tests rely on the swap persisting across + // Execute and don't run newRootCmd in parallel, so leaving it set is safe. version = v root := &cobra.Command{ diff --git a/internal/api/client.go b/internal/api/client.go index 3040aa2..0c80299 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -19,9 +19,6 @@ import ( // defaultRequestTimeout is generous because real-time verify can spend ~30s SMTP-probing slow MX hosts. const defaultRequestTimeout = 60 * time.Second -// Retry knobs for transient API responses. maxRetrySleep caps the per-attempt -// wait so a misbehaving server can't wedge us for hours; minRetrySleep ensures a -// brief pause even when the server returned an unparseable / zero Reset. const ( defaultMaxRetries = 2 maxRetrySleep = 60 * time.Second @@ -77,12 +74,6 @@ func NewWithOptions(baseURL, accessToken string, opts Options) *Client { } } -// do issues an HTTP request with the configured auth headers and decodes a -// JSON response. Non-2xx responses return an *Error. -// -// 429 responses trigger an automatic retry honoring the RateLimit-Reset -// timestamp, capped at c.maxRetries attempts. Each retry rebuilds the request -// from scratch since the form body Reader has already been consumed. func (c *Client) do(ctx context.Context, method, path string, query url.Values, form url.Values, out any) error { fullURL := strings.TrimRight(c.baseURL, "/") + path if len(query) > 0 { @@ -160,11 +151,6 @@ func isRetryableStatus(status int) bool { return status == 249 || status == http.StatusTooManyRequests } -// backoffFor picks how long to wait before retrying a transient API response. -// Prefers the documented RateLimit-Reset timestamp, then falls back to an -// exponential default. A small random jitter is added so concurrent CLIs don't -// synchronize on the same retry instant. -// The result is clamped to [minRetrySleep, maxRetrySleep]. func backoffFor(rl *RateLimit, attempt int, now time.Time) time.Duration { base := time.Duration(0) if rl != nil && rl.Reset > 0 { @@ -230,10 +216,6 @@ func indentLines(s string) string { return strings.Join(lines, "\n") } -// parseRateLimit reads the documented `RateLimit-*` headers off h and returns a -// populated *RateLimit when at least one is present. Missing or unparseable -// values stay zero rather than failing. RateLimit-Reset is a Unix timestamp in -// seconds. func parseRateLimit(h http.Header) *RateLimit { limit := h.Get("RateLimit-Limit") remaining := h.Get("RateLimit-Remaining") diff --git a/internal/api/errors.go b/internal/api/errors.go index 5f8188e..4d3ffaf 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -8,8 +8,7 @@ import ( // ErrUnauthenticated is returned when the API responds with a 401 status. var ErrUnauthenticated = errors.New("api: not authenticated") -// RateLimit captures the documented `RateLimit-*` response headers. All fields -// are zero when the corresponding header was absent or unparseable. +// RateLimit holds the parsed RateLimit-* response headers. type RateLimit struct { Limit int // requests allowed per window Remaining int // requests left in the current window diff --git a/internal/output/human.go b/internal/output/human.go index de1cff6..982d707 100644 --- a/internal/output/human.go +++ b/internal/output/human.go @@ -250,9 +250,9 @@ func scoreBadgeBG(score int, state string) lipgloss.Color { } } -// PrintVerifyResult renders a single verify result. The `user` and `duration` -// fields are intentionally omitted here; JSON output retains them. -// PrintVerifyResult renders r as a labeled card with state, score, and attribute sections. +// PrintVerifyResult renders r as a labeled card with state, score, and +// attribute sections. The user and duration fields are intentionally omitted +// here; JSON output retains them. func (h *Human) PrintVerifyResult(r *api.VerifyResult) error { stf := styler(h.W) labelStyle := stf(lipgloss.NewStyle().Foreground(lipgloss.Color("241")))