From af1788de657f636fc802780596714f849c4baa6d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 4 Sep 2026 08:25:59 +0000 Subject: [PATCH 1/2] Survive a missing xdg-open during login On a server with no desktop, spawn("xdg-open") reports ENOENT as an asynchronous "error" event rather than throwing, so the try/catch in openBrowser caught nothing and the unhandled event ended `myna login x` a moment after it had printed the paste-the-code prompt. Attach an error handler; the caller prints the link anyway, which is all the user needs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HWRBUb6VUCM2xBmRk91FzG --- packages/core/src/net/oauth2.ts | 6 +++++ packages/core/test/open-browser.test.ts | 31 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 packages/core/test/open-browser.test.ts diff --git a/packages/core/src/net/oauth2.ts b/packages/core/src/net/oauth2.ts index 5852e7c..c32e72a 100644 --- a/packages/core/src/net/oauth2.ts +++ b/packages/core/src/net/oauth2.ts @@ -75,6 +75,12 @@ export async function openBrowser(url: string): Promise { const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; try { const child = spawn(command, args, { stdio: "ignore", detached: true }); + // A missing opener (a server with no xdg-open) is reported here, on a + // later tick, not by throwing. Unhandled, that event ends the process + // right after the authorize link has been printed. + child.on("error", () => { + /* the caller prints the URL too */ + }); child.unref(); } catch { /* the caller prints the URL too */ diff --git a/packages/core/test/open-browser.test.ts b/packages/core/test/open-browser.test.ts new file mode 100644 index 0000000..194981b --- /dev/null +++ b/packages/core/test/open-browser.test.ts @@ -0,0 +1,31 @@ +/** + * Raising a browser must never take the login down with it. + * + * On a server with no desktop there is no xdg-open. spawn() reports that as an + * asynchronous "error" event, not a throw, so the try/catch around it caught + * nothing and the unhandled event killed `myna login x` a moment after it had + * printed the paste-the-code prompt. The link was already on screen; the user + * only needed the process to stay alive. + */ +import { test, expect } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openBrowser } from "../src/net/oauth2.ts"; + +test("a missing browser opener is not fatal", async () => { + const path = process.env.PATH; + const uncaught: unknown[] = []; + const catchAll = (error: unknown) => { uncaught.push(error); }; + process.env.PATH = mkdtempSync(join(tmpdir(), "myna-nopath-")); + process.on("uncaughtException", catchAll); + try { + await openBrowser("https://example.test/authorize"); + // The spawn error arrives on a later tick; give it time to surface. + await new Promise((resolve) => setTimeout(resolve, 100)); + } finally { + process.env.PATH = path; + process.removeListener("uncaughtException", catchAll); + } + expect(uncaught).toEqual([]); +}); From 20370ea15bcdd5124f701b5c32a5c5a8849ae049 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 4 Sep 2026 08:26:38 +0000 Subject: [PATCH 2/2] Type the listener removal so typecheck stays clean Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HWRBUb6VUCM2xBmRk91FzG --- packages/core/test/open-browser.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/test/open-browser.test.ts b/packages/core/test/open-browser.test.ts index 194981b..c952c41 100644 --- a/packages/core/test/open-browser.test.ts +++ b/packages/core/test/open-browser.test.ts @@ -8,6 +8,7 @@ * only needed the process to stay alive. */ import { test, expect } from "bun:test"; +import { EventEmitter } from "node:events"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -25,7 +26,8 @@ test("a missing browser opener is not fatal", async () => { await new Promise((resolve) => setTimeout(resolve, 100)); } finally { process.env.PATH = path; - process.removeListener("uncaughtException", catchAll); + // Bun types process.removeListener more narrowly than process.on. + (process as unknown as EventEmitter).removeListener("uncaughtException", catchAll); } expect(uncaught).toEqual([]); });