Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/src/net/oauth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export async function openBrowser(url: string): Promise<void> {
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 */
Expand Down
33 changes: 33 additions & 0 deletions packages/core/test/open-browser.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});