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..c952c41 --- /dev/null +++ b/packages/core/test/open-browser.test.ts @@ -0,0 +1,33 @@ +/** + * 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 { EventEmitter } from "node:events"; +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; + // Bun types process.removeListener more narrowly than process.on. + (process as unknown as EventEmitter).removeListener("uncaughtException", catchAll); + } + expect(uncaught).toEqual([]); +});