[object Object]

← back to Cli Printing Press

fix(cli): make cookie-auth login --chrome work on Windows (#1404)

fdca9f7aa3bd697e3153e4852d7905752cbf5d47 · 2026-05-14 12:38:32 -0700 · Trevin Chow

* fix(cli): make cookie-auth login --chrome work on Windows

The generated `auth login --chrome` flow had two stacked Windows blockers:

1. `detectCookieTool` probed pycookiecheat with a hardcoded `python3`, which
   does not exist on Windows installs that ship `python.exe` or the `py`
   launcher. The probe failed, the user was told "no cookie tool found",
   and the install hint advised `pip install pycookiecheat` even though the
   user already had it installed.
2. pycookiecheat itself raises `OSError("This script only works on MacOS
   or Linux.")` on Windows, so even with a working Python binary the
   extraction crashed once it reached `chrome_cookies()`.

Resolve both:

- New `resolvePythonBinary` helper tries `python3`, then `python`, then
  `py -3`, and plumbs the resolved binary into both the import probe and
  the extraction call.
- `detectCookieTool` now short-circuits the pycookiecheat probe on
  `runtime.GOOS == "windows"` and falls through to `cookies` /
  `cookie-scoop`. When nothing is available on Windows it surfaces a
  Windows-specific error pointing at `auth login --browser` (live Chrome
  via CDP) and cookie-scoop-cli.
- The "Install one of" user-facing hint is now platform-aware: Windows
  users get the working workaround instead of advice to install a tool
  that does not support their platform.

Regression test asserts the generated auth.go contains the resolver, the
Windows skip, and the Windows-specific fallback prose.

Closes #1101

* fix(cli): thread resolved Python binary through cookieTool struct

Greptile review on PR #1404:

- P1 (test): `assert.Contains(t, content, "\"python\"")` was vacuously
  satisfied as a substring of `"python3"`. Anchor the assertions to the
  struct-literal forms `{"python3", nil}`, `{"python", nil}`, and
  `{"py", []string{"-3"}}` so an accidental removal of any candidate
  fails the test.
- P2 (runtime): `detectCookieTool` and `extractViaPycookiecheat` both
  called `resolvePythonBinary` independently. If PATH mutated between
  detection and extraction, the probe could succeed against python3 while
  extraction silently fell through to a different interpreter. Thread the
  resolved `(bin, args)` pair on a new `cookieTool` struct so the same
  Python is used end to end. `cookieToolSupportsProfiles` now takes
  `tool.name`; the other branches (cookies, cookie-scoop) carry empty
  pyBin/pyArgs.

Files touched

Diff

commit fdca9f7aa3bd697e3153e4852d7905752cbf5d47
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Thu May 14 12:38:32 2026 -0700

    fix(cli): make cookie-auth login --chrome work on Windows (#1404)
    
    * fix(cli): make cookie-auth login --chrome work on Windows
    
    The generated `auth login --chrome` flow had two stacked Windows blockers:
    
    1. `detectCookieTool` probed pycookiecheat with a hardcoded `python3`, which
       does not exist on Windows installs that ship `python.exe` or the `py`
       launcher. The probe failed, the user was told "no cookie tool found",
       and the install hint advised `pip install pycookiecheat` even though the
       user already had it installed.
    2. pycookiecheat itself raises `OSError("This script only works on MacOS
       or Linux.")` on Windows, so even with a working Python binary the
       extraction crashed once it reached `chrome_cookies()`.
    
    Resolve both:
    
    - New `resolvePythonBinary` helper tries `python3`, then `python`, then
      `py -3`, and plumbs the resolved binary into both the import probe and
      the extraction call.
    - `detectCookieTool` now short-circuits the pycookiecheat probe on
      `runtime.GOOS == "windows"` and falls through to `cookies` /
      `cookie-scoop`. When nothing is available on Windows it surfaces a
      Windows-specific error pointing at `auth login --browser` (live Chrome
      via CDP) and cookie-scoop-cli.
    - The "Install one of" user-facing hint is now platform-aware: Windows
      users get the working workaround instead of advice to install a tool
      that does not support their platform.
    
    Regression test asserts the generated auth.go contains the resolver, the
    Windows skip, and the Windows-specific fallback prose.
    
    Closes #1101
    
    * fix(cli): thread resolved Python binary through cookieTool struct
    
    Greptile review on PR #1404:
    
    - P1 (test): `assert.Contains(t, content, "\"python\"")` was vacuously
      satisfied as a substring of `"python3"`. Anchor the assertions to the
      struct-literal forms `{"python3", nil}`, `{"python", nil}`, and
      `{"py", []string{"-3"}}` so an accidental removal of any candidate
      fails the test.
    - P2 (runtime): `detectCookieTool` and `extractViaPycookiecheat` both
      called `resolvePythonBinary` independently. If PATH mutated between
      detection and extraction, the probe could succeed against python3 while
      extraction silently fell through to a different interpreter. Thread the
      resolved `(bin, args)` pair on a new `cookieTool` struct so the same
      Python is used end to end. `cookieToolSupportsProfiles` now takes
      `tool.name`; the other branches (cookies, cookie-scoop) carry empty
      pyBin/pyArgs.
---
 internal/generator/generator_test.go              | 65 ++++++++++++++++
 internal/generator/templates/auth_browser.go.tmpl | 94 +++++++++++++++++------
 2 files changed, 134 insertions(+), 25 deletions(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 11b7325c..66072459 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -6326,6 +6326,71 @@ func TestGenerate_CookieAuthUsesBrowserTemplate(t *testing.T) {
 	runGoCommand(t, outputDir, "build", "./...")
 }
 
+// TestGenerate_CookieAuthWindowsCompatibility asserts the cookie-auth template
+// emits a portable Python resolver and a Windows-aware fallback for the
+// `auth login --chrome` flow. Regression for issue #1101: hardcoded `python3`
+// and a non-Windows-friendly install hint made every cookie-auth CLI unusable
+// on Windows.
+func TestGenerate_CookieAuthWindowsCompatibility(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "winapp",
+		Version: "0.1.0",
+		BaseURL: "https://app.example.com",
+		Auth: spec.AuthConfig{
+			Type:         "cookie",
+			Header:       "Cookie",
+			In:           "cookie",
+			CookieDomain: ".example.com",
+			EnvVars:      []string{"WINAPP_COOKIES"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/winapp-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Description: "Manage items",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/api/items", Description: "List items"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "winapp-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	auth, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "auth.go"))
+	require.NoError(t, err)
+	content := string(auth)
+
+	// Python resolver tries all three common binary names rather than only `python3`.
+	// Match the struct-literal needles so `"python"` isn't vacuously satisfied as
+	// a substring of `"python3"`.
+	assert.Contains(t, content, "resolvePythonBinary")
+	assert.Contains(t, content, `{"python3", nil}`)
+	assert.Contains(t, content, `{"python", nil}`)
+	assert.Contains(t, content, `{"py", []string{"-3"}}`)
+
+	// pycookiecheat is skipped on Windows because it raises OSError there.
+	assert.Contains(t, content, `runtime.GOOS != "windows"`)
+	assert.Contains(t, content, "pycookiecheat does not support Windows")
+
+	// The resolved (bin, args) pair is carried on cookieTool so detection and
+	// extraction cannot disagree about which interpreter to invoke.
+	assert.Contains(t, content, "type cookieTool struct")
+	assert.Contains(t, content, "pyBin")
+	assert.Contains(t, content, "pyArgs")
+	assert.NotContains(t, content, `exec.Command("python3", "-c", script)`)
+	assert.Contains(t, content, `exec.Command(tool.pyBin,`)
+
+	// Windows users get a workable next step instead of the Unix install hint.
+	assert.Contains(t, content, "auth login --browser")
+	assert.Contains(t, content, "cookie-scoop-cli")
+}
+
 func TestGenerate_UserAgentOverrideGatedByBrowserTransport(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/generator/templates/auth_browser.go.tmpl b/internal/generator/templates/auth_browser.go.tmpl
index de2dda92..4fea558d 100644
--- a/internal/generator/templates/auth_browser.go.tmpl
+++ b/internal/generator/templates/auth_browser.go.tmpl
@@ -118,20 +118,27 @@ profile by name when the installed backend supports it.`,
 			if err != nil {
 				fmt.Fprintln(w, red("No cookie extraction tool found."))
 				fmt.Fprintln(w, "")
-				fmt.Fprintln(w, "Install one of:")
-				fmt.Fprintln(w, "  pip install pycookiecheat          # Python (recommended)")
-				fmt.Fprintln(w, "  brew install barnardb/cookies/cookies  # Homebrew")
-				fmt.Fprintln(w, "  cargo install cookie-scoop-cli     # Rust")
+				if runtime.GOOS == "windows" {
+					fmt.Fprintln(w, "pycookiecheat does not support Windows. Read cookies from a live Chrome session instead:")
+					fmt.Fprintf(w, "\n  {{.Name}}-pp-cli auth login --browser\n\n")
+					fmt.Fprintln(w, "Or install a cross-platform reader:")
+					fmt.Fprintln(w, "  cargo install cookie-scoop-cli     # Rust")
+				} else {
+					fmt.Fprintln(w, "Install one of:")
+					fmt.Fprintln(w, "  pip install pycookiecheat          # Python (recommended)")
+					fmt.Fprintln(w, "  brew install barnardb/cookies/cookies  # Homebrew")
+					fmt.Fprintln(w, "  cargo install cookie-scoop-cli     # Rust")
+				}
 				return authErr(fmt.Errorf("no cookie tool found"))
 			}
 
-			if profileFlag != "" && !cookieToolSupportsProfiles(tool) {
-				return authErr(fmt.Errorf("%s does not support --profile; install pycookiecheat or cookie-scoop-cli", tool))
+			if profileFlag != "" && !cookieToolSupportsProfiles(tool.name) {
+				return authErr(fmt.Errorf("%s does not support --profile; install pycookiecheat or cookie-scoop-cli", tool.name))
 			}
 
 			// Step 2: Resolve which Chrome profile to use when the backend can honor it
 			profileDir := ""
-			if cookieToolSupportsProfiles(tool) {
+			if cookieToolSupportsProfiles(tool.name) {
 				profileDir, err = resolveChromeProfile(w, cmd.InOrStdin(), domain, profileFlag)
 				if err != nil {
 					loginURL := "https://" + strings.TrimPrefix(domain, ".")
@@ -1135,41 +1142,78 @@ func resolveProfileByName(name string) (string, error) {
 
 // --- Cookie extraction tools ---
 
-// detectCookieTool checks for available cookie extraction tools in preference order.
-func detectCookieTool() (string, error) {
-	tools := []struct {
-		name  string
-		check []string
+// resolvePythonBinary finds a usable Python 3 interpreter, returning the
+// executable name and any leading args (e.g. `py -3` on Windows). pycookiecheat
+// is the only tool the CLI shells into Python for, and Windows ships
+// `python.exe` or the `py` launcher rather than `python3`.
+func resolvePythonBinary() (string, []string, bool) {
+	candidates := []struct {
+		bin  string
+		args []string
 	}{
-		{"pycookiecheat", []string{"python3", "-c", "import pycookiecheat"}},
-		{"cookies", []string{"cookies", "--help"}},
-		{"cookie-scoop", []string{"cookie-scoop", "--help"}},
+		{"python3", nil},
+		{"python", nil},
+		{"py", []string{"-3"}},
 	}
+	for _, c := range candidates {
+		if _, err := exec.LookPath(c.bin); err == nil {
+			return c.bin, c.args, true
+		}
+	}
+	return "", nil, false
+}
+
+// cookieTool bundles the detected tool name with any auxiliary invocation
+// data the extraction call needs. For pycookiecheat we carry the Python
+// binary and its leading args so detection and extraction cannot disagree
+// if PATH mutates between calls.
+type cookieTool struct {
+	name   string
+	pyBin  string
+	pyArgs []string
+}
 
-	for _, tool := range tools {
-		if err := exec.Command(tool.check[0], tool.check[1:]...).Run(); err == nil {
-			return tool.name, nil
+// detectCookieTool checks for available cookie extraction tools in preference order.
+// pycookiecheat is skipped on Windows because upstream raises
+// `OSError("This script only works on MacOS or Linux.")` regardless of whether
+// the import probe succeeds.
+func detectCookieTool() (cookieTool, error) {
+	if runtime.GOOS != "windows" {
+		if bin, args, ok := resolvePythonBinary(); ok {
+			probeArgs := append(append([]string{}, args...), "-c", "import pycookiecheat")
+			if err := exec.Command(bin, probeArgs...).Run(); err == nil {
+				return cookieTool{name: "pycookiecheat", pyBin: bin, pyArgs: args}, nil
+			}
 		}
 	}
-	return "", fmt.Errorf("no cookie extraction tool found; install one: pip install pycookiecheat")
+	if err := exec.Command("cookies", "--help").Run(); err == nil {
+		return cookieTool{name: "cookies"}, nil
+	}
+	if err := exec.Command("cookie-scoop", "--help").Run(); err == nil {
+		return cookieTool{name: "cookie-scoop"}, nil
+	}
+	if runtime.GOOS == "windows" {
+		return cookieTool{}, fmt.Errorf("no cookie extraction tool found; pycookiecheat does not support Windows. Use `auth login --browser` (live Chrome via CDP) or install cookie-scoop-cli")
+	}
+	return cookieTool{}, fmt.Errorf("no cookie extraction tool found; install one: pip install pycookiecheat")
 }
 
 // extractCookies shells out to the detected tool and returns a Cookie header value string.
 // profileDir selects which Chrome profile to read from (e.g. "Default", "Profile 1").
-func extractCookies(tool, domain, profileDir string) (string, error) {
-	switch tool {
+func extractCookies(tool cookieTool, domain, profileDir string) (string, error) {
+	switch tool.name {
 	case "pycookiecheat":
-		return extractViaPycookiecheat(domain, profileDir)
+		return extractViaPycookiecheat(tool, domain, profileDir)
 	case "cookies":
 		return extractViaCookiesCLI(domain)
 	case "cookie-scoop":
 		return extractViaCookieScoop(domain, profileDir)
 	default:
-		return "", fmt.Errorf("unknown cookie tool: %s", tool)
+		return "", fmt.Errorf("unknown cookie tool: %s", tool.name)
 	}
 }
 
-func extractViaPycookiecheat(domain, profileDir string) (string, error) {
+func extractViaPycookiecheat(tool cookieTool, domain, profileDir string) (string, error) {
 	cleanDomain := strings.TrimPrefix(domain, ".")
 	cookiePath := ""
 	if profileDir != "" && profileDir != "Default" {
@@ -1195,7 +1239,7 @@ func extractViaPycookiecheat(domain, profileDir string) (string, error) {
 	}
 
 	var out bytes.Buffer
-	cmd := exec.Command("python3", "-c", script)
+	cmd := exec.Command(tool.pyBin, append(append([]string{}, tool.pyArgs...), "-c", script)...)
 	cmd.Stdout = &out
 	cmd.Stderr = os.Stderr
 	if err := cmd.Run(); err != nil {

← 89f38c4c fix(cli): isolate typed-table upsert failures with per-item  ·  back to Cli Printing Press  ·  fix(cli): tighten phone-us PII regex to NANP-valid first dig 15964974 →