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") +}