diff --git a/README.md b/README.md
index d60867d..b3b3d20 100644
--- a/README.md
+++ b/README.md
@@ -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
` where `` 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"
```
@@ -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
diff --git a/cmd/itemize/main.go b/cmd/itemize/main.go
index 47dccfc..bf953eb 100644
--- a/cmd/itemize/main.go
+++ b/cmd/itemize/main.go
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
+ "strings"
"github.com/eshaffer321/itemize/internal/adapters/clients"
"github.com/eshaffer321/itemize/internal/adapters/providers"
@@ -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 -account ' to create one.")
+ fmt.Println("Run 'itemize amazon -import-browser-profile -account ' to create one.")
return
}
fmt.Println("Saved Amazon accounts:")
@@ -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 {
@@ -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()
@@ -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"
}
@@ -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)")
@@ -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 -account ' first")
+ fmt.Println(" Run 'itemize amazon -import-browser-profile -account ' first")
fmt.Println(" AMAZON_COOKIE_FILE Explicit amazon-go cookie file (optional)")
}
diff --git a/docs/bug-fixes.md b/docs/bug-fixes.md
index 0be2d77..490c180 100644
--- a/docs/bug-fixes.md
+++ b/docs/bug-fixes.md
@@ -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 [-account ]`.
+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:**
@@ -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.
diff --git a/internal/adapters/providers/amazon/provider.go b/internal/adapters/providers/amazon/provider.go
index e78199d..99306f9 100644
--- a/internal/adapters/providers/amazon/provider.go
+++ b/internal/adapters/providers/amazon/provider.go
@@ -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{
@@ -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
}
@@ -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 -cookie-file %q' to authenticate", p.cookieFile)
+ return fmt.Sprintf("run 'itemize amazon -import-browser-profile -cookie-file %q' to authenticate", p.cookieFile)
}
- return fmt.Sprintf("run 'amazon-go import-browser-profile -profile-dir %s' to authenticate", accountArg)
+ return fmt.Sprintf("run 'itemize amazon -import-browser-profile %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 {
diff --git a/internal/adapters/providers/amazon/provider_test.go b/internal/adapters/providers/amazon/provider_test.go
index 7e2e78e..4a2d4d3 100644
--- a/internal/adapters/providers/amazon/provider_test.go
+++ b/internal/adapters/providers/amazon/provider_test.go
@@ -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 -account wife")
+ assert.Contains(t, err.Error(), "itemize amazon -import-browser-profile -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{
@@ -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 -account wife")
+ assert.Contains(t, err.Error(), "itemize amazon -import-browser-profile -account wife")
}
func TestConvertGoTransactionMapsRefundAndPendingStatuses(t *testing.T) {
@@ -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 ")
}
type fakeAmazonClient struct {
diff --git a/internal/cli/amazon_auth.go b/internal/cli/amazon_auth.go
new file mode 100644
index 0000000..71384a9
--- /dev/null
+++ b/internal/cli/amazon_auth.go
@@ -0,0 +1,458 @@
+package cli
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "syscall"
+
+ amazon "github.com/eshaffer321/amazon-go"
+ "github.com/eshaffer321/itemize/internal/infrastructure/config"
+)
+
+const amazonOrdersURL = "https://www.amazon.com/gp/css/order-history?disableCsd=no-js"
+
+// AmazonImportOptions controls Amazon browser-profile cookie import.
+type AmazonImportOptions struct {
+ ProfileDir string
+ Account string
+ CookieFile string
+ PlaywrightRoot string
+ Headless bool
+ SkipAuthCheck bool
+ Out io.Writer
+}
+
+// RunAmazonBrowserProfileImport imports Amazon cookies into itemize's Amazon cookie store.
+func RunAmazonBrowserProfileImport(cfg *config.Config, opts AmazonImportOptions) error {
+ if opts.ProfileDir == "" {
+ return fmt.Errorf("-import-browser-profile requires a profile directory")
+ }
+ if cfg != nil && opts.CookieFile == "" {
+ opts.CookieFile = cfg.Providers.Amazon.CookieFile
+ }
+ if opts.Out == nil {
+ opts.Out = os.Stdout
+ }
+
+ resolvedProfileDir, err := expandUserPath(opts.ProfileDir)
+ if err != nil {
+ return fmt.Errorf("failed to resolve profile dir: %w", err)
+ }
+ if info, err := os.Stat(resolvedProfileDir); err != nil || !info.IsDir() {
+ if err == nil {
+ err = fmt.Errorf("not a directory")
+ }
+ return fmt.Errorf("invalid profile dir %q: %w", resolvedProfileDir, err)
+ }
+ if err := cleanStaleChromiumSingletons(resolvedProfileDir); err != nil {
+ return err
+ }
+
+ root, err := resolvePlaywrightRoot(opts.PlaywrightRoot)
+ if err != nil {
+ return err
+ }
+
+ cookies, title, err := exportAmazonCookiesWithPlaywright(root, resolvedProfileDir, opts.Headless)
+ if err != nil {
+ return fmt.Errorf("failed to export cookies from browser profile: %w", explainAmazonCookieExportError(err))
+ }
+ if len(cookies) == 0 {
+ return fmt.Errorf("no Amazon cookies were available in the browser profile")
+ }
+
+ if err := saveImportedAmazonCookies(cookies, opts); err != nil {
+ var authErr *amazonImportAuthCheckError
+ if errors.As(err, &authErr) {
+ return fmt.Errorf("exported %d cookies from %q, but auth check failed: %w", len(cookies), resolvedProfileDir, authErr.err)
+ }
+ return err
+ }
+
+ destination := opts.CookieFile
+ if destination == "" && opts.Account != "" {
+ destination = "account " + opts.Account
+ }
+ if destination == "" {
+ destination = "the default Amazon account"
+ }
+ if _, err := fmt.Fprintf(opts.Out, "Imported %d Amazon cookies from %q (%s) into %s.\n", len(cookies), resolvedProfileDir, title, destination); err != nil {
+ return fmt.Errorf("failed to write import result: %w", err)
+ }
+ if !opts.SkipAuthCheck {
+ if _, err := fmt.Fprintln(opts.Out, "Auth check passed."); err != nil {
+ return fmt.Errorf("failed to write auth check result: %w", err)
+ }
+ }
+ return nil
+}
+
+type amazonImportAuthCheckError struct {
+ err error
+}
+
+func (e *amazonImportAuthCheckError) Error() string {
+ return e.err.Error()
+}
+
+func (e *amazonImportAuthCheckError) Unwrap() error {
+ return e.err
+}
+
+func saveImportedAmazonCookies(cookies []*amazon.Cookie, opts AmazonImportOptions) error {
+ if !opts.SkipAuthCheck {
+ if err := validateImportedAmazonCookies(cookies); err != nil {
+ return &amazonImportAuthCheckError{err: err}
+ }
+ }
+
+ client, err := newAmazonClient(opts.CookieFile, opts.Account)
+ if err != nil {
+ return fmt.Errorf("failed to create Amazon client: %w", err)
+ }
+ for _, c := range cookies {
+ client.CookieStore().Set(c)
+ }
+ if err := client.SaveCookies(); err != nil {
+ return fmt.Errorf("failed to save cookies: %w", err)
+ }
+ return nil
+}
+
+func validateImportedAmazonCookies(cookies []*amazon.Cookie) error {
+ tempFile, err := os.CreateTemp("", "itemize-amazon-auth-*.json")
+ if err != nil {
+ return fmt.Errorf("failed to create temporary Amazon auth file: %w", err)
+ }
+ tempPath := tempFile.Name()
+ if err := tempFile.Close(); err != nil {
+ _ = os.Remove(tempPath)
+ return fmt.Errorf("failed to close temporary Amazon auth file: %w", err)
+ }
+ defer func() { _ = os.Remove(tempPath) }()
+
+ client, err := newAmazonClient(tempPath, "")
+ if err != nil {
+ return fmt.Errorf("failed to create Amazon validation client: %w", err)
+ }
+ for _, c := range cookies {
+ client.CookieStore().Set(c)
+ }
+ return client.HealthCheck()
+}
+
+type exportedAmazonCookies struct {
+ Title string `json:"title"`
+ Cookies []exportedBrowserCookie `json:"cookies"`
+}
+
+type exportedBrowserCookie struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+ Domain string `json:"domain"`
+ Path string `json:"path"`
+ Expires float64 `json:"expires"`
+ Secure bool `json:"secure"`
+ HttpOnly bool `json:"httpOnly"`
+}
+
+func exportAmazonCookiesWithPlaywright(playwrightRoot, profileDir string, headless bool) ([]*amazon.Cookie, string, error) {
+ scriptPath, err := writeAmazonCookieExportScript(playwrightRoot)
+ if err != nil {
+ return nil, "", err
+ }
+ defer func() { _ = os.Remove(scriptPath) }()
+
+ // #nosec G204 -- exec.Command does not invoke a shell; arguments are passed
+ // directly to Node. scriptPath is a temporary file we just wrote, and the
+ // profile path is data consumed by the script.
+ cmd := exec.Command("node", scriptPath, profileDir, amazonOrdersURL, fmt.Sprintf("%t", headless))
+ cmd.Dir = playwrightRoot
+ out, err := cmd.Output()
+ if err != nil {
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ return nil, "", fmt.Errorf("node/playwright failed: %s", strings.TrimSpace(string(exitErr.Stderr)))
+ }
+ return nil, "", err
+ }
+
+ var exported exportedAmazonCookies
+ if err := json.Unmarshal(out, &exported); err != nil {
+ return nil, "", fmt.Errorf("failed to parse Playwright cookie export: %w", err)
+ }
+
+ cookies := make([]*amazon.Cookie, 0, len(exported.Cookies))
+ for _, c := range exported.Cookies {
+ if c.Name == "" || c.Value == "" || !domainMatchesAmazon(c.Domain) {
+ continue
+ }
+ cookies = append(cookies, &amazon.Cookie{
+ Name: c.Name,
+ Value: c.Value,
+ Domain: c.Domain,
+ Path: c.Path,
+ Expires: int64(c.Expires),
+ Secure: c.Secure,
+ HttpOnly: c.HttpOnly,
+ })
+ }
+
+ return cookies, exported.Title, nil
+}
+
+func explainAmazonCookieExportError(err error) error {
+ if err == nil {
+ return nil
+ }
+ message := err.Error()
+ lower := strings.ToLower(message)
+ if strings.Contains(lower, "amazon sign-in") ||
+ strings.Contains(lower, "profile did not open an authenticated amazon orders page") {
+ return fmt.Errorf("browser profile is not logged into Amazon. Open the profile, sign into Amazon, then rerun without -headless so Amazon can refresh the session")
+ }
+ if strings.Contains(lower, "target page, context or browser has been closed") ||
+ strings.Contains(lower, "sigtrap") {
+ return fmt.Errorf("chromium closed before itemize could read Amazon cookies. Try again with -headless, or use a fresh Chromium/Playwright profile directory and sign into Amazon there")
+ }
+ return err
+}
+
+var chromiumProcessExists = processExists
+
+func cleanStaleChromiumSingletons(profileDir string) error {
+ lockPath := filepath.Join(profileDir, "SingletonLock")
+ target, err := os.Readlink(lockPath)
+ if err != nil {
+ if os.IsNotExist(err) || err == syscall.EINVAL {
+ return nil
+ }
+ return fmt.Errorf("failed to inspect Chromium profile lock: %w", err)
+ }
+
+ dash := strings.LastIndex(target, "-")
+ if dash == -1 || dash == len(target)-1 {
+ return nil
+ }
+ pid, err := strconv.Atoi(target[dash+1:])
+ if err != nil {
+ return nil
+ }
+ if chromiumProcessExists(pid) {
+ return nil
+ }
+
+ for _, name := range []string{"SingletonLock", "SingletonSocket", "SingletonCookie"} {
+ path := filepath.Join(profileDir, name)
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("failed to remove stale Chromium profile marker %q: %w", path, err)
+ }
+ }
+ return nil
+}
+
+func processExists(pid int) bool {
+ if pid <= 0 {
+ return false
+ }
+ process, err := os.FindProcess(pid)
+ if err != nil {
+ return false
+ }
+ err = process.Signal(syscall.Signal(0))
+ return err == nil || errors.Is(err, syscall.EPERM)
+}
+
+func writeAmazonCookieExportScript(playwrightRoot string) (string, error) {
+ script := `const { chromium } = require("playwright");
+
+async function main() {
+ const profileDir = process.argv[2];
+ const ordersURL = process.argv[3];
+ const headless = process.argv[4] === "true";
+ const options = {
+ headless,
+ viewport: { width: 1280, height: 800 },
+ locale: "en-US"
+ };
+ if (headless) {
+ const version = await chromiumVersion();
+ options.userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + version + " Safari/537.36";
+ options.extraHTTPHeaders = {
+ "sec-ch-ua": "\"Chromium\";v=\"" + version.split(".")[0] + "\", \"Not A(Brand\";v=\"24\"",
+ "sec-ch-ua-platform": "\"macOS\""
+ };
+ }
+ const context = await chromium.launchPersistentContext(profileDir, options);
+ if (headless) {
+ await context.addInitScript(() => {
+ Object.defineProperty(navigator, "webdriver", { get: () => undefined });
+ Object.defineProperty(navigator, "languages", { get: () => ["en-US", "en"] });
+ Object.defineProperty(navigator, "plugins", { get: () => [1, 2, 3, 4, 5] });
+ });
+ }
+ try {
+ const page = await context.newPage();
+ await page.goto(ordersURL, { waitUntil: "domcontentloaded", timeout: 30000 });
+ await page.waitForTimeout(2000);
+ const state = await page.evaluate(() => {
+ const body = document.body ? document.body.innerText : "";
+ const login = !!document.querySelector("input#ap_email,input[name='email'],input#ap_password,input[name='password']") ||
+ /amazon sign-in/i.test(document.title);
+ const ready = !login && (body.includes("Your Orders") || document.querySelectorAll(".js-order-card,.order-card").length > 0);
+ return { login, ready, title: document.title };
+ });
+ if (!state.ready) {
+ if (headless) {
+ throw new Error("profile did not open an authenticated Amazon orders page (title=" + JSON.stringify(state.title) + ", login=" + state.login + ")");
+ }
+ await page.waitForFunction(() => {
+ const body = document.body ? document.body.innerText : "";
+ const login = !!document.querySelector("input#ap_email,input[name='email'],input#ap_password,input[name='password']") ||
+ /amazon sign-in/i.test(document.title);
+ return !login && (body.includes("Your Orders") || document.querySelectorAll(".js-order-card,.order-card").length > 0);
+ }, null, { timeout: 300000 }).catch(() => {
+ throw new Error("Amazon sign-in did not complete within 5 minutes");
+ });
+ }
+ const cookies = await context.cookies(["https://www.amazon.com", ordersURL]);
+ const amazonCookies = cookies.filter(c => ["amazon.com", ".amazon.com", "www.amazon.com", ".www.amazon.com"].includes(c.domain));
+ console.log(JSON.stringify({ title: await page.title(), cookies: amazonCookies }));
+ } finally {
+ await context.close();
+ }
+}
+
+async function chromiumVersion() {
+ const browser = await chromium.launch({ headless: true });
+ try {
+ return await browser.version();
+ } finally {
+ await browser.close();
+ }
+}
+
+main().catch(err => {
+ console.error(err && err.stack ? err.stack : String(err));
+ process.exit(1);
+});`
+
+ f, err := os.CreateTemp(playwrightRoot, "itemize-amazon-import-*.cjs")
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = f.Close() }()
+ if _, err := f.WriteString(script); err != nil {
+ _ = os.Remove(f.Name())
+ return "", err
+ }
+ return f.Name(), nil
+}
+
+func resolvePlaywrightRoot(explicit string) (string, error) {
+ var candidates []string
+ if explicit != "" {
+ candidates = append(candidates, explicit)
+ }
+
+ if cwd, err := os.Getwd(); err == nil {
+ candidates = append(candidates, cwd)
+ for dir := cwd; ; {
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
+ }
+ candidates = append(candidates, parent)
+ dir = parent
+ }
+ }
+
+ if root, err := npmRoot("-g"); err == nil && root != "" {
+ candidates = append(candidates, filepath.Dir(root), root)
+ }
+
+ if home, err := os.UserHomeDir(); err == nil {
+ matches, _ := filepath.Glob(filepath.Join(home, ".npm", "_npx", "*", "node_modules", "playwright", "package.json"))
+ sort.Strings(matches)
+ for i := len(matches) - 1; i >= 0; i-- {
+ candidates = append(candidates, filepath.Dir(filepath.Dir(filepath.Dir(matches[i]))))
+ }
+ }
+
+ seen := make(map[string]bool)
+ for _, candidate := range candidates {
+ if candidate == "" {
+ continue
+ }
+ candidate, _ = expandUserPath(candidate)
+ if seen[candidate] {
+ continue
+ }
+ seen[candidate] = true
+ if hasPlaywright(candidate) {
+ return candidate, nil
+ }
+ if filepath.Base(candidate) == "playwright" && fileExists(filepath.Join(candidate, "package.json")) {
+ return filepath.Dir(filepath.Dir(candidate)), nil
+ }
+ }
+
+ return "", fmt.Errorf("install Playwright with `npm install playwright` or pass -playwright-root pointing at a directory containing node_modules/playwright")
+}
+
+func npmRoot(args ...string) (string, error) {
+ cmdArgs := append([]string{"root"}, args...)
+ // #nosec G204 -- args are fixed internal values ("root", optionally "-g")
+ // used to locate Playwright; no shell is invoked.
+ out, err := exec.Command("npm", cmdArgs...).Output()
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+func hasPlaywright(root string) bool {
+ return fileExists(filepath.Join(root, "node_modules", "playwright", "package.json"))
+}
+
+func fileExists(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && !info.IsDir()
+}
+
+func newAmazonClient(cookieFile, account string) (*amazon.Client, error) {
+ opts := []amazon.Option{amazon.WithAutoSave(false)}
+ if cookieFile != "" {
+ opts = append(opts, amazon.WithCookieFile(cookieFile))
+ }
+ if account != "" {
+ opts = append(opts, amazon.WithAccount(account))
+ }
+ return amazon.NewClient(opts...)
+}
+
+func expandUserPath(path string) (string, error) {
+ if path == "" {
+ return "", nil
+ }
+ if strings.HasPrefix(path, "~/") {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ path = filepath.Join(home, path[2:])
+ }
+ return filepath.Abs(path)
+}
+
+func domainMatchesAmazon(domain string) bool {
+ return domain == "amazon.com" || domain == ".amazon.com" ||
+ domain == "www.amazon.com" || domain == ".www.amazon.com"
+}
diff --git a/internal/cli/amazon_auth_test.go b/internal/cli/amazon_auth_test.go
new file mode 100644
index 0000000..585d3b0
--- /dev/null
+++ b/internal/cli/amazon_auth_test.go
@@ -0,0 +1,109 @@
+package cli
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ amazon "github.com/eshaffer321/amazon-go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestExplainAmazonCookieExportError_WhenProfileOpensSignIn(t *testing.T) {
+ err := explainAmazonCookieExportError(errors.New(`Error: profile did not open an authenticated Amazon orders page (title="Amazon Sign-In", login=true)`))
+
+ assert.Contains(t, err.Error(), "browser profile is not logged into Amazon")
+ assert.Contains(t, err.Error(), "rerun without -headless")
+}
+
+func TestExplainAmazonCookieExportError_WhenBrowserCrashes(t *testing.T) {
+ err := explainAmazonCookieExportError(errors.New("browserType.launchPersistentContext: Target page, context or browser has been closed\nsignal=SIGTRAP"))
+
+ assert.Contains(t, err.Error(), "chromium closed before itemize could read Amazon cookies")
+ assert.Contains(t, err.Error(), "-headless")
+}
+
+func TestSaveImportedAmazonCookies_DoesNotOverwriteDestinationWhenValidationFails(t *testing.T) {
+ cookieFile := filepath.Join(t.TempDir(), "cookies-amazon-wife.json")
+ original := []byte(`{"cookies":[{"name":"original","value":"keep-me","domain":".amazon.com","path":"/"}],"updated_at":"2026-01-01T00:00:00Z"}`)
+ require.NoError(t, os.WriteFile(cookieFile, original, 0600))
+
+ err := saveImportedAmazonCookies([]*amazon.Cookie{
+ {Name: "session-id", Value: "missing-other-essential-cookies", Domain: ".amazon.com", Path: "/"},
+ }, AmazonImportOptions{CookieFile: cookieFile})
+
+ require.Error(t, err)
+ var authErr *amazonImportAuthCheckError
+ assert.ErrorAs(t, err, &authErr)
+ after, readErr := os.ReadFile(cookieFile)
+ require.NoError(t, readErr)
+ assert.JSONEq(t, string(original), string(after))
+}
+
+func TestSaveImportedAmazonCookies_SavesDestinationWhenAuthCheckSkipped(t *testing.T) {
+ cookieFile := filepath.Join(t.TempDir(), "cookies-amazon-wife.json")
+
+ err := saveImportedAmazonCookies([]*amazon.Cookie{
+ {Name: "session-id", Value: "sid", Domain: ".amazon.com", Path: "/"},
+ {Name: "session-token", Value: "token", Domain: ".amazon.com", Path: "/"},
+ {Name: "ubid-main", Value: "ubid", Domain: ".amazon.com", Path: "/"},
+ {Name: "at-main", Value: "at", Domain: ".amazon.com", Path: "/"},
+ }, AmazonImportOptions{CookieFile: cookieFile, SkipAuthCheck: true})
+
+ require.NoError(t, err)
+ data, err := os.ReadFile(cookieFile)
+ require.NoError(t, err)
+ var stored struct {
+ Cookies []amazon.Cookie `json:"cookies"`
+ }
+ require.NoError(t, json.Unmarshal(data, &stored))
+ assert.Len(t, stored.Cookies, 4)
+}
+
+func TestCleanStaleChromiumSingletons_RemovesMarkersForDeadPid(t *testing.T) {
+ dir := t.TempDir()
+ requireSymlink(t, "host-999999", filepath.Join(dir, "SingletonLock"))
+ requireSymlink(t, "/tmp/chrome/SingletonSocket", filepath.Join(dir, "SingletonSocket"))
+ requireSymlink(t, "cookie", filepath.Join(dir, "SingletonCookie"))
+
+ orig := chromiumProcessExists
+ chromiumProcessExists = func(pid int) bool {
+ assert.Equal(t, 999999, pid)
+ return false
+ }
+ t.Cleanup(func() { chromiumProcessExists = orig })
+
+ require.NoError(t, cleanStaleChromiumSingletons(dir))
+
+ assert.NoFileExists(t, filepath.Join(dir, "SingletonLock"))
+ assert.NoFileExists(t, filepath.Join(dir, "SingletonSocket"))
+ assert.NoFileExists(t, filepath.Join(dir, "SingletonCookie"))
+}
+
+func TestCleanStaleChromiumSingletons_KeepsMarkersForLivePid(t *testing.T) {
+ dir := t.TempDir()
+ requireSymlink(t, "host-12345", filepath.Join(dir, "SingletonLock"))
+ requireSymlink(t, "/tmp/chrome/SingletonSocket", filepath.Join(dir, "SingletonSocket"))
+ requireSymlink(t, "cookie", filepath.Join(dir, "SingletonCookie"))
+
+ orig := chromiumProcessExists
+ chromiumProcessExists = func(pid int) bool {
+ assert.Equal(t, 12345, pid)
+ return true
+ }
+ t.Cleanup(func() { chromiumProcessExists = orig })
+
+ require.NoError(t, cleanStaleChromiumSingletons(dir))
+
+ assert.FileExists(t, filepath.Join(dir, "SingletonLock"))
+ assert.FileExists(t, filepath.Join(dir, "SingletonSocket"))
+ assert.FileExists(t, filepath.Join(dir, "SingletonCookie"))
+}
+
+func requireSymlink(t *testing.T, target, path string) {
+ t.Helper()
+ require.NoError(t, os.Symlink(target, path))
+}
diff --git a/internal/cli/flags.go b/internal/cli/flags.go
index 20b279a..60e3c16 100644
--- a/internal/cli/flags.go
+++ b/internal/cli/flags.go
@@ -10,14 +10,20 @@ import (
// SyncFlags are common flags for all sync commands
type SyncFlags struct {
- DryRun bool
- LookbackDays int
- MaxOrders int
- Force bool
- Verbose bool
- OrderID string
- Account string
- ListAccounts bool
+ DryRun bool
+ LookbackDays int
+ MaxOrders int
+ Force bool
+ Verbose bool
+ OrderID string
+ Account string
+ CookieFile string
+ ListAccounts bool
+ ImportBrowserProfile string
+ PlaywrightRoot string
+ Headless bool
+ SkipAuthCheck bool
+ ExtraArgs []string
}
// ParseSyncFlags parses common sync flags from command line
@@ -30,7 +36,12 @@ func ParseSyncFlags() SyncFlags {
flag.BoolVar(&flags.Verbose, "verbose", false, "Verbose output")
flag.StringVar(&flags.OrderID, "order-id", "", "Process only this specific order ID (limits blast radius)")
flag.StringVar(&flags.Account, "account", "", "Amazon cookie account name (overrides AMAZON_ACCOUNT_NAME; run -list-accounts to see saved accounts)")
+ flag.StringVar(&flags.CookieFile, "cookie-file", "", "Explicit Amazon cookie file (overrides AMAZON_COOKIE_FILE)")
flag.BoolVar(&flags.ListAccounts, "list-accounts", false, "List saved Amazon cookie accounts and exit")
+ flag.StringVar(&flags.ImportBrowserProfile, "import-browser-profile", "", "Import Amazon cookies from this Chromium/Playwright browser profile and exit")
+ flag.StringVar(&flags.PlaywrightRoot, "playwright-root", "", "Directory containing node_modules/playwright for Amazon cookie import")
+ flag.BoolVar(&flags.Headless, "headless", false, "Run Amazon browser profile import headlessly")
+ flag.BoolVar(&flags.SkipAuthCheck, "skip-auth-check", false, "Skip Amazon auth validation after importing cookies")
flag.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage: itemize [flags]")
@@ -47,11 +58,12 @@ func ParseSyncFlags() SyncFlags {
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Provider-Specific Environment Variables:")
fmt.Fprintln(os.Stderr, " AMAZON_ACCOUNT_NAME Amazon cookie account name (optional)")
- fmt.Fprintln(os.Stderr, " Run 'amazon-go import-browser-profile -profile-dir -account ' first")
+ fmt.Fprintln(os.Stderr, " Run 'itemize amazon -import-browser-profile -account ' first")
fmt.Fprintln(os.Stderr, " AMAZON_COOKIE_FILE Explicit amazon-go cookie file (optional)")
}
flag.Parse()
+ flags.ExtraArgs = flag.Args()
return flags
}
diff --git a/internal/cli/providers.go b/internal/cli/providers.go
index 8d53199..dd73cbe 100644
--- a/internal/cli/providers.go
+++ b/internal/cli/providers.go
@@ -100,6 +100,28 @@ func NewAmazonProvider(cfg *config.Config, verbose bool, account string) (provid
return amazonprovider.NewProvider(amazonLogger, providerCfg), nil
}
+// ResolveAmazonAccount returns the explicit Amazon cookie account for this run.
+// A single positional account is accepted for the common `itemize amazon wife`
+// shape, but ambiguous mixes are rejected so arguments are never ignored.
+func ResolveAmazonAccount(cfg *config.Config, flagAccount string, extraArgs []string) (string, error) {
+ if len(extraArgs) > 1 {
+ return "", fmt.Errorf("unexpected arguments: %s; use -account %s", strings.Join(extraArgs, " "), extraArgs[0])
+ }
+ if len(extraArgs) == 1 {
+ if flagAccount != "" {
+ return "", fmt.Errorf("use either -account or a positional account, not both")
+ }
+ return extraArgs[0], nil
+ }
+ if flagAccount != "" {
+ return flagAccount, nil
+ }
+ if cfg != nil {
+ return cfg.Providers.Amazon.AccountName, nil
+ }
+ return "", nil
+}
+
// ListAmazonAccounts returns the names of saved amazon-go cookie accounts found
// under ~/.amazon-go (files named cookies-.json).
func ListAmazonAccounts(cfg *config.Config) ([]string, error) {
diff --git a/internal/cli/providers_test.go b/internal/cli/providers_test.go
index c6d8f9f..1408faa 100644
--- a/internal/cli/providers_test.go
+++ b/internal/cli/providers_test.go
@@ -37,3 +37,38 @@ func TestListAmazonAccounts_MissingDirReturnsEmptyNotError(t *testing.T) {
require.NoError(t, err, "an amazon-go cookie dir that was never created is a normal first-run state, not an error")
assert.Empty(t, accounts)
}
+
+func TestResolveAmazonAccount_UsesSinglePositionalAccount(t *testing.T) {
+ cfg := &config.Config{}
+ account, err := ResolveAmazonAccount(cfg, "", []string{"amazon-wife"})
+
+ require.NoError(t, err)
+ assert.Equal(t, "amazon-wife", account)
+}
+
+func TestResolveAmazonAccount_RejectsUnexpectedExtraArgs(t *testing.T) {
+ cfg := &config.Config{}
+ _, err := ResolveAmazonAccount(cfg, "", []string{"amazon-wife", "extra"})
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unexpected arguments")
+ assert.Contains(t, err.Error(), "-account amazon-wife")
+}
+
+func TestResolveAmazonAccount_FlagBeatsConfig(t *testing.T) {
+ cfg := &config.Config{}
+ cfg.Providers.Amazon.AccountName = "from-config"
+
+ account, err := ResolveAmazonAccount(cfg, "from-flag", nil)
+
+ require.NoError(t, err)
+ assert.Equal(t, "from-flag", account)
+}
+
+func TestResolveAmazonAccount_RejectsPositionalAccountWhenFlagSet(t *testing.T) {
+ cfg := &config.Config{}
+ _, err := ResolveAmazonAccount(cfg, "from-flag", []string{"from-positional"})
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "use either -account or a positional account")
+}