diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e737a3..ff5087e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2.1.2 + +- Keep `login` waiting for the callback when no browser can be opened, and print the URL to open instead of failing (#72). +- On WSL, open the login URL in the Windows browser through `wslview` or PowerShell before falling back to `xdg-open`. + ## 2.0.16 - Use the official pinned Supermemory 5.0.1 SDK for hosted content, preserving namespace strings, batch IDs, metadata, search defaults, capture cadence, and permissions. diff --git a/package.json b/package.json index a52228f..7daa459 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-supermemory", - "version": "2.1.1", + "version": "2.1.2", "description": "OpenCode plugin that gives coding agents persistent memory using Supermemory", "type": "module", "main": "dist/index.js", diff --git a/src/services/auth.test.ts b/src/services/auth.test.ts new file mode 100644 index 0000000..8f9a9a6 --- /dev/null +++ b/src/services/auth.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +test("login keeps waiting for the callback when no browser can be opened", async () => { + const oauth = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req, server) { + const issuer = `http://127.0.0.1:${server.port}`; + const url = new URL(req.url); + switch (url.pathname) { + case "/.well-known/oauth-authorization-server": + return Response.json({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + registration_endpoint: `${issuer}/register`, + revocation_endpoint: `${issuer}/revoke`, + }); + case "/register": + return Response.json({ client_id: "test-client", token_endpoint_auth_method: "none" }); + case "/token": + return Response.json({ + access_token: "test-access", + refresh_token: "test-refresh", + token_type: "Bearer", + expires_in: 3600, + }); + default: + return new Response(null, { status: 404 }); + } + }, + }); + const issuer = `http://127.0.0.1:${oauth.port}`; + const home = mkdtempSync(join(tmpdir(), "auth-home-")); + const login = Bun.spawn([process.execPath, join(import.meta.dir, "../cli.ts"), "login"], { + env: { + HOME: home, + PATH: mkdtempSync(join(tmpdir(), "no-opener-")), + SUPERMEMORY_API_URL: issuer, + SUPERMEMORY_OAUTH_ISSUER: issuer, + SUPERMEMORY_MCP_URL: `${issuer}/mcp`, + SUPERMEMORY_AUTH_TIMEOUT: "10000", + }, + stdout: "pipe", + }); + + try { + const reader = login.stdout.getReader(); + const decoder = new TextDecoder(); + let output = ""; + while (!output.includes("Open the URL above")) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value); + } + expect(output).toContain("Could not open a browser. Open the URL above to finish signing in."); + const authorizationUrl = new URL(output.match(/visit: (\S+)/)![1]!); + const callback = new URL(authorizationUrl.searchParams.get("redirect_uri")!); + callback.search = new URLSearchParams({ + code: "test-code", + state: authorizationUrl.searchParams.get("state")!, + }).toString(); + + expect((await fetch(callback)).status).toBe(200); + expect(await login.exited).toBe(0); + const credentials = JSON.parse( + readFileSync(join(home, ".supermemory-opencode", "credentials.json"), "utf-8"), + ); + expect(credentials.accessToken).toBe("test-access"); + } finally { + login.kill(); + oauth.stop(true); + } +}, 15_000); diff --git a/src/services/auth.ts b/src/services/auth.ts index 58618e3..2c1c266 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -589,9 +589,7 @@ export async function startAuthFlow( console.log("Opening browser for authentication..."); console.log(`If it doesn't open, visit: ${authorizationUrl.toString()}`); openUrl(authorizationUrl).catch(() => - reject( - new Error("Failed to open the browser. Retry login from an interactive terminal."), - ), + console.log("Could not open a browser. Open the URL above to finish signing in."), ); }); }); diff --git a/src/services/openUrl.test.ts b/src/services/openUrl.test.ts new file mode 100644 index 0000000..f22b5d5 --- /dev/null +++ b/src/services/openUrl.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { openUrl } from "./openUrl.js"; + +const URL_WITH_SPECIALS = "https://example.com/a?x=1&y=a%20b&q='it'&z=%26"; +const saved = { + PATH: process.env.PATH, + WSL_DISTRO_NAME: process.env.WSL_DISTRO_NAME, + WSL_INTEROP: process.env.WSL_INTEROP, +}; +let bin: string; + +function stub(...names: string[]): void { + for (const name of names) { + const file = join(bin, name); + writeFileSync(file, `#!/bin/sh\nprintf '%s\\n' "$@" > "${file}.args"\n`); + chmodSync(file, 0o755); + } +} + +function opened(name: string): string[] | undefined { + const file = join(bin, `${name}.args`); + return existsSync(file) ? readFileSync(file, "utf-8").trimEnd().split("\n") : undefined; +} + +describe.skipIf(process.platform !== "linux")("openUrl on Linux", () => { + beforeEach(() => { + bin = mkdtempSync(join(tmpdir(), "open-url-")); + process.env.PATH = bin; + delete process.env.WSL_DISTRO_NAME; + delete process.env.WSL_INTEROP; + }); + + afterEach(() => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + test("prefers wslview on WSL", async () => { + process.env.WSL_DISTRO_NAME = "Ubuntu"; + stub("wslview", "powershell.exe", "xdg-open"); + await openUrl(URL_WITH_SPECIALS); + expect(opened("wslview")).toEqual([URL_WITH_SPECIALS]); + expect(opened("powershell.exe")).toBeUndefined(); + expect(opened("xdg-open")).toBeUndefined(); + }); + + test("passes the URL intact to PowerShell on WSL without wslview", async () => { + process.env.WSL_INTEROP = "/run/WSL/1_interop"; + stub("powershell.exe", "xdg-open"); + await openUrl(URL_WITH_SPECIALS); + const encoded = opened("powershell.exe")!.at(-1)!; + expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/); + expect(Buffer.from(encoded, "base64").toString("utf16le")).toBe( + `Start-Process '${URL_WITH_SPECIALS.replaceAll("'", "''")}'`, + ); + expect(opened("xdg-open")).toBeUndefined(); + }); + + test("falls back to xdg-open on WSL without Windows openers", async () => { + process.env.WSL_DISTRO_NAME = "Ubuntu"; + stub("xdg-open"); + await openUrl(URL_WITH_SPECIALS); + expect(opened("xdg-open")).toEqual([URL_WITH_SPECIALS]); + }); + + test.skipIf(/microsoft/i.test(readFileSync("/proc/version", "utf-8")))( + "ignores WSL openers outside WSL", + async () => { + stub("wslview", "xdg-open"); + await openUrl(URL_WITH_SPECIALS); + expect(opened("xdg-open")).toEqual([URL_WITH_SPECIALS]); + expect(opened("wslview")).toBeUndefined(); + }, + ); +}); diff --git a/src/services/openUrl.ts b/src/services/openUrl.ts index dae8053..3c0e9c0 100644 --- a/src/services/openUrl.ts +++ b/src/services/openUrl.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; function run(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { @@ -9,6 +10,26 @@ function run(command: string, args: string[]): Promise { }); } +function isWsl(): boolean { + if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return true; + try { + return /microsoft/i.test(readFileSync("/proc/version", "utf-8")); + } catch { + return false; + } +} + +// Base64 keeps & and quotes in the URL away from WSL interop and Windows command-line parsing. +function openWithPowerShell(href: string): Promise { + const script = `Start-Process '${href.replaceAll("'", "''")}'`; + return run("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + Buffer.from(script, "utf16le").toString("base64"), + ]); +} + export async function openUrl(url: string | URL): Promise { const href = url.toString(); if (!/^https?:\/\//i.test(href)) { @@ -30,5 +51,17 @@ export async function openUrl(url: string | URL): Promise { return; } + if (isWsl()) { + try { + await run("wslview", [href]); + return; + } catch {} + + try { + await openWithPowerShell(href); + return; + } catch {} + } + await run("xdg-open", [href]); } diff --git a/src/version.ts b/src/version.ts index 3b850aa..16d28d6 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,3 +1,3 @@ // AUTO-GENERATED by scripts/sync-version.mjs — do not edit by hand. // Sourced from package.json "version" so the two cannot drift. -export const PLUGIN_VERSION = "2.1.1"; +export const PLUGIN_VERSION = "2.1.2";