Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,20 +135,30 @@ Requires cookies in `~/.walmart-api/cookies.json`. See [walmart-client-go](https
Uses credentials saved by [costco-go](https://github.com/eshaffer321/costco-go).

### Amazon
Uses [amazon-go](https://github.com/eshaffer321/amazon-go) directly. Authenticate once by
Uses [amazon-go](https://github.com/eshaffer321/amazon-go) as a library. Authenticate once by
saving Amazon cookies from a browser profile that is already logged into Amazon:

The browser-profile import uses Playwright to open Chromium. If Playwright is not already
installed globally or in this repo, install it once:

```bash
npm install playwright
```

If Playwright lives somewhere else, pass `-playwright-root <dir>` where `<dir>` contains
`node_modules/playwright`.

```bash
amazon-go import-browser-profile \
-profile-dir "$HOME/.itemize/amazon/amazon-erick" \
./itemize amazon \
-import-browser-profile "$HOME/.itemize/amazon/amazon-erick" \
-account erick
```

If you set `AMAZON_ACCOUNT_NAME`, save cookies for the matching amazon-go account:

```bash
amazon-go import-browser-profile \
-profile-dir "$HOME/.itemize/amazon/$AMAZON_ACCOUNT_NAME" \
./itemize amazon \
-import-browser-profile "$HOME/.itemize/amazon/$AMAZON_ACCOUNT_NAME" \
-account "$AMAZON_ACCOUNT_NAME"
```

Expand All @@ -161,6 +171,7 @@ an env var, or for cron jobs where the account name should be visible right in t
```bash
./itemize amazon -list-accounts # see saved amazon-go cookie accounts
./itemize amazon -account amazon-wife # sync a specific account
./itemize amazon amazon-wife # shorthand for the same account
```

`AMAZON_ACCOUNT_NAME` still works and is used as the default when `-account` is omitted — cron
Expand Down
65 changes: 57 additions & 8 deletions cmd/itemize/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
"strings"

"github.com/eshaffer321/itemize/internal/adapters/clients"
"github.com/eshaffer321/itemize/internal/adapters/providers"
Expand Down Expand Up @@ -51,26 +52,49 @@ func main() {
// Provider sync commands
providerName := command
// Shift args for flag parsing
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
if providerName == "amazon" && len(os.Args) > 2 && !strings.HasPrefix(os.Args[2], "-") {
os.Args = append([]string{os.Args[0], "-account", os.Args[2]}, os.Args[3:]...)
} else {
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
}

// Parse common flags
flags := cli.ParseSyncFlags()

// Load config
cfg := config.LoadOrEnv()
if flags.CookieFile != "" {
cfg.Providers.Amazon.CookieFile = flags.CookieFile
}

var err error
if providerName != "amazon" && len(flags.ExtraArgs) > 0 {
log.Fatalf("Unexpected positional arguments are only supported for the amazon provider")
}

amazonAccount := ""
if providerName == "amazon" {
amazonAccount, err = cli.ResolveAmazonAccount(cfg, flags.Account, flags.ExtraArgs)
if err != nil {
log.Fatalf("Invalid Amazon account arguments: %v", err)
}
}

if flags.ListAccounts {
if providerName != "amazon" {
fmt.Printf("-list-accounts is only supported for the amazon provider\n")
os.Exit(1)
}
if len(flags.ExtraArgs) > 0 {
log.Fatalf("-list-accounts does not accept account arguments; use -account when syncing")
}
accounts, err := cli.ListAmazonAccounts(cfg)
if err != nil {
log.Fatalf("Failed to list Amazon accounts: %v", err)
}
if len(accounts) == 0 {
fmt.Println("No saved Amazon accounts found.")
fmt.Println("Run 'amazon-go import-browser-profile -profile-dir <profile-dir> -account <name>' to create one.")
fmt.Println("Run 'itemize amazon -import-browser-profile <profile-dir> -account <name>' to create one.")
return
}
fmt.Println("Saved Amazon accounts:")
Expand All @@ -82,6 +106,26 @@ func main() {
return
}

if flags.ImportBrowserProfile != "" {
if providerName != "amazon" {
fmt.Printf("-import-browser-profile is only supported for the amazon provider\n")
os.Exit(1)
}
if err := cli.RunAmazonBrowserProfileImport(cfg, cli.AmazonImportOptions{
ProfileDir: flags.ImportBrowserProfile,
Account: amazonAccount,
CookieFile: cfg.Providers.Amazon.CookieFile,
PlaywrightRoot: flags.PlaywrightRoot,
Headless: flags.Headless,
SkipAuthCheck: flags.SkipAuthCheck,
}); err != nil {
// Auth import failures can include local browser profile paths.
// Keep them on the user's terminal instead of sending telemetry.
log.Fatalf("Amazon authentication failed: %v", err)
}
return
}

// Initialize shared dependencies
serviceClients, err := clients.NewClients(cfg)
if err != nil {
Expand All @@ -106,7 +150,7 @@ func main() {
case "walmart":
provider, err = cli.NewWalmartProvider(cfg, flags.Verbose)
case "amazon":
provider, err = cli.NewAmazonProvider(cfg, flags.Verbose, flags.Account)
provider, err = cli.NewAmazonProvider(cfg, flags.Verbose, amazonAccount)
default:
fmt.Printf("Unknown provider: %s\n", providerName)
printUsage()
Expand All @@ -128,10 +172,7 @@ func main() {
// obvious which profile is in use without digging through env vars)
resolvedAccount := ""
if providerName == "amazon" {
resolvedAccount = flags.Account
if resolvedAccount == "" {
resolvedAccount = cfg.Providers.Amazon.AccountName
}
resolvedAccount = amazonAccount
if resolvedAccount == "" {
resolvedAccount = "default"
}
Expand Down Expand Up @@ -180,7 +221,15 @@ func printUsage() {
fmt.Println(" -verbose Verbose output")
fmt.Println(" -order-id string Process only this specific order ID (limits blast radius)")
fmt.Println(" -account string Amazon cookie account name (amazon only)")
fmt.Println(" -cookie-file string")
fmt.Println(" Explicit Amazon cookie file (amazon only)")
fmt.Println(" -list-accounts List saved Amazon cookie accounts and exit (amazon only)")
fmt.Println(" -import-browser-profile string")
fmt.Println(" Import Amazon cookies from a Chromium/Playwright profile and exit (amazon only)")
fmt.Println(" -playwright-root string")
fmt.Println(" Directory containing node_modules/playwright for Amazon cookie import")
fmt.Println(" -headless Run Amazon browser profile import headlessly")
fmt.Println(" -skip-auth-check Skip Amazon auth validation after importing cookies")
fmt.Println()
fmt.Println("Environment Variables:")
fmt.Println(" MONARCH_TOKEN Monarch API token (required)")
Expand All @@ -191,6 +240,6 @@ func printUsage() {
fmt.Println()
fmt.Println("Provider-Specific Environment Variables:")
fmt.Println(" AMAZON_ACCOUNT_NAME Amazon cookie account name (optional)")
fmt.Println(" Run 'amazon-go import-browser-profile -profile-dir <profile-dir> -account <name>' first")
fmt.Println(" Run 'itemize amazon -import-browser-profile <profile-dir> -account <name>' first")
fmt.Println(" AMAZON_COOKIE_FILE Explicit amazon-go cookie file (optional)")
}
26 changes: 25 additions & 1 deletion docs/bug-fixes.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,30 @@ Each bug fix entry should include:

## Bug Fixes

### 2026-07-08: Amazon auth recovery leaked amazon-go and ignored positional accounts

**Description:**
Running `./itemize amazon amazon-wife` looked like it selected the `amazon-wife` cookie account, but the extra positional argument was silently ignored and the sync used the default Amazon account. When cookies were expired, the recovery message then told users to run `amazon-go import-browser-profile`, a command that is not available in a fresh itemize checkout or binary install.

**Test Case:**
```go
// internal/cli/providers_test.go: TestResolveAmazonAccount_UsesSinglePositionalAccount
// internal/adapters/providers/amazon/provider_test.go: TestProvider_FetchOrdersReturnsHealthCheckError
// Expected: positional Amazon account is honored, ambiguous extras are rejected,
// and auth failures point at `itemize amazon -import-browser-profile`.
```

**Fix Applied:**
Added an itemize-native Amazon cookie import path:
`itemize amazon -import-browser-profile <profile-dir> [-account <name>]`.
The CLI now resolves a single positional Amazon account as shorthand for `-account`, rejects ambiguous extra arguments, and lets `-cookie-file` override `AMAZON_COOKIE_FILE` so suggested recovery commands are runnable.

**Verification:**
- `go test ./internal/adapters/providers/amazon ./internal/cli` passes.
- `go test ./...` passes.

---

### 2026-07-08: Costco split success could be overwritten by later no-match failure

**Description:**
Expand Down Expand Up @@ -111,7 +135,7 @@ While swapping Amazon from the npm scraper to `amazon-go`, a dry-run over 365 da
The provider fetch path trusted `FetchOrders` directly. The Go client can return no orders when a cookie jar is stale unless auth is checked first, which hides the stale-session problem from the CLI.

**Fix Applied:**
The Amazon provider now calls `HealthCheck()` before order fetches and wraps failures with the relevant `amazon-go import-browser-profile` command. Itemize now consumes `amazon-go v0.3.0`, which includes the parser/auth detection fix for the `Amazon Sign-In` title variant.
The Amazon provider now calls `HealthCheck()` before order fetches and wraps failures with the relevant browser-profile import command. Itemize now consumes `amazon-go v0.3.0`, which includes the parser/auth detection fix for the `Amazon Sign-In` title variant.

**Verification:**
- `TestProvider_FetchOrdersReturnsHealthCheckError` passes.
Expand Down
16 changes: 12 additions & 4 deletions internal/adapters/providers/amazon/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func (p *Provider) FetchOrders(ctx context.Context, opts providers.FetchOptions)
return nil, err
}
if err := client.HealthCheck(); err != nil {
return nil, fmt.Errorf("amazon auth check failed: %w. %s", err, p.loginCommand())
return nil, p.authCheckError(err)
}

amazonOrders, err := client.FetchOrders(ctx, amazongo.FetchOptions{
Expand Down Expand Up @@ -191,7 +191,7 @@ func (p *Provider) HealthCheck(ctx context.Context) error {
return err
}
if err := client.HealthCheck(); err != nil {
return fmt.Errorf("amazon auth check failed: %w. %s", err, p.loginCommand())
return p.authCheckError(err)
}
return nil
}
Expand Down Expand Up @@ -240,9 +240,17 @@ func (p *Provider) loginCommand() string {
accountArg = " -account " + p.profile
}
if p.cookieFile != "" {
return fmt.Sprintf("run 'amazon-go import-browser-profile -profile-dir <profile-dir> -cookie-file %q' to authenticate", p.cookieFile)
return fmt.Sprintf("run 'itemize amazon -import-browser-profile <profile-dir> -cookie-file %q' to authenticate", p.cookieFile)
}
return fmt.Sprintf("run 'amazon-go import-browser-profile -profile-dir <profile-dir>%s' to authenticate", accountArg)
return fmt.Sprintf("run 'itemize amazon -import-browser-profile <profile-dir>%s' to authenticate", accountArg)
}

func (p *Provider) authCheckError(err error) error {
message := err.Error()
if strings.Contains(strings.ToLower(message), "cookies are expired") {
message = "Amazon rejected the saved cookies or opened a sign-in page"
}
return fmt.Errorf("amazon auth check failed: %s. %s", message, p.loginCommand())
}

func convertGoOrder(order *amazongo.Order, transactions []*amazongo.Transaction) *ParsedOrder {
Expand Down
28 changes: 26 additions & 2 deletions internal/adapters/providers/amazon/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,33 @@ func TestProvider_FetchOrdersReturnsHealthCheckError(t *testing.T) {
require.Error(t, err)
assert.Nil(t, orders)
assert.Contains(t, err.Error(), "amazon auth check failed")
assert.Contains(t, err.Error(), "amazon-go import-browser-profile -profile-dir <profile-dir> -account wife")
assert.Contains(t, err.Error(), "itemize amazon -import-browser-profile <profile-dir> -account wife")
assert.True(t, client.healthChecked)
}

func TestProvider_FetchOrdersRewordsExpiredCookieError(t *testing.T) {
client := &fakeAmazonClient{healthErr: errors.New("authentication failed: cookies are expired, please re-import cookies from browser")}
provider := NewProviderWithClient(nil, &ProviderConfig{Profile: "wife"}, client)

_, err := provider.FetchOrders(context.Background(), providers.FetchOptions{IncludeDetails: true})

require.Error(t, err)
assert.Contains(t, err.Error(), "amazon auth check failed")
assert.Contains(t, err.Error(), "Amazon rejected the saved cookies or opened a sign-in page")
assert.NotContains(t, err.Error(), "cookies are expired")
}

func TestProvider_HealthCheckRewordsExpiredCookieError(t *testing.T) {
client := &fakeAmazonClient{healthErr: errors.New("authentication failed: cookies are expired, please re-import cookies from browser")}
provider := NewProviderWithClient(nil, &ProviderConfig{Profile: "wife"}, client)

err := provider.HealthCheck(context.Background())

require.Error(t, err)
assert.Contains(t, err.Error(), "Amazon rejected the saved cookies or opened a sign-in page")
assert.NotContains(t, err.Error(), "cookies are expired")
}

func TestProvider_GetOrderDetailsFetchesOrderWithTransactions(t *testing.T) {
client := &fakeAmazonClient{
detailOrder: &amazongo.Order{
Expand Down Expand Up @@ -204,7 +227,7 @@ func TestProvider_HealthCheckWrapsAuthErrorWithLoginCommand(t *testing.T) {

require.Error(t, err)
assert.Contains(t, err.Error(), "amazon auth check failed")
assert.Contains(t, err.Error(), "amazon-go import-browser-profile -profile-dir <profile-dir> -account wife")
assert.Contains(t, err.Error(), "itemize amazon -import-browser-profile <profile-dir> -account wife")
}

func TestConvertGoTransactionMapsRefundAndPendingStatuses(t *testing.T) {
Expand All @@ -221,6 +244,7 @@ func TestLoginCommandUsesExplicitCookieFile(t *testing.T) {
provider := NewProvider(nil, &ProviderConfig{CookieFile: "/tmp/amazon-cookies.json"})

assert.Contains(t, provider.loginCommand(), `-cookie-file "/tmp/amazon-cookies.json"`)
assert.Contains(t, provider.loginCommand(), "itemize amazon -import-browser-profile <profile-dir>")
}

type fakeAmazonClient struct {
Expand Down
Loading