🐛 fix: cli login and run browser in Windows (#12787)

* 🐛 fix: support authoritize in no browser environment

* wip: remove tests

* 📝 docs: remove redundant alerts

* 🐛 fix: could not invoke brower in windows

* wip: add link and unlink cli to global
This commit is contained in:
Rylan Cai
2026-03-07 23:33:05 +08:00
committed by GitHub
parent b91fa68b31
commit e48fd47d4e
4 changed files with 121 additions and 17 deletions

View File

@@ -10,6 +10,8 @@
],
"scripts": {
"build": "npx tsup",
"cli:link": "bun link",
"cli:unlink": "bun unlink",
"dev": "bun src/index.ts",
"prepublishOnly": "npm run build",
"test": "bunx vitest run --config vitest.config.mts --silent='passed-only'",

View File

@@ -1,9 +1,11 @@
import fs from 'node:fs';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { saveCredentials } from '../auth/credentials';
import { log } from '../utils/logger';
import { registerLoginCommand } from './login';
import { registerLoginCommand, resolveCommandExecutable } from './login';
vi.mock('../auth/credentials', () => ({
saveCredentials: vi.fn(),
@@ -26,6 +28,9 @@ vi.mock('node:child_process', () => ({
describe('login command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
const originalPath = process.env.PATH;
const originalPathext = process.env.PATHEXT;
const originalSystemRoot = process.env.SystemRoot;
beforeEach(() => {
vi.useFakeTimers();
@@ -36,6 +41,9 @@ describe('login command', () => {
afterEach(() => {
vi.useRealTimers();
exitSpy.mockRestore();
process.env.PATH = originalPath;
process.env.PATHEXT = originalPathext;
process.env.SystemRoot = originalSystemRoot;
vi.restoreAllMocks();
});
@@ -248,4 +256,17 @@ describe('login command', () => {
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should resolve Windows executable via PATHEXT', () => {
process.env.PATH = 'C:\\Tools';
process.env.PATHEXT = '.EXE;.CMD';
process.env.SystemRoot = 'C:\\Windows';
vi.spyOn(fs, 'existsSync').mockImplementation(
(targetPath) => String(targetPath).toLowerCase() === 'c:\\tools\\rundll32.exe',
);
const resolved = resolveCommandExecutable('rundll32', 'win32');
expect(resolved?.toLowerCase()).toBe('c:\\tools\\rundll32.exe');
});
});

View File

@@ -1,4 +1,6 @@
import { execFile } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import type { Command } from 'commander';
@@ -33,6 +35,17 @@ interface TokenErrorResponse {
error_description?: string;
}
async function parseJsonResponse<T>(res: Response, endpoint: string): Promise<T> {
try {
return (await res.json()) as T;
} catch {
const contentType = res.headers.get('content-type') || 'unknown';
throw new Error(
`Expected JSON from ${endpoint}, got non-JSON response (status=${res.status}, content-type=${contentType}).`,
);
}
}
export function registerLoginCommand(program: Command) {
program
.command('login')
@@ -62,7 +75,7 @@ export function registerLoginCommand(program: Command) {
process.exit(1);
}
deviceAuth = (await res.json()) as DeviceAuthResponse;
deviceAuth = await parseJsonResponse<DeviceAuthResponse>(res, '/oidc/device/auth');
} catch (error: any) {
log.error(`Failed to reach server: ${error.message}`);
log.error(`Make sure ${serverUrl} is reachable.`);
@@ -80,7 +93,10 @@ export function registerLoginCommand(program: Command) {
log.info('');
// Try to open browser automatically
openBrowser(verifyUrl);
const opened = await openBrowser(verifyUrl);
if (!opened) {
log.warn('Could not open browser automatically.');
}
log.info('Waiting for authorization...');
@@ -104,7 +120,10 @@ export function registerLoginCommand(program: Command) {
method: 'POST',
});
const body = (await res.json()) as TokenResponse & TokenErrorResponse;
const body = await parseJsonResponse<TokenResponse & TokenErrorResponse>(
res,
'/oidc/token',
);
// Check body for error field — some proxies may return 200 for error responses
if (body.error) {
@@ -159,20 +178,81 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function openBrowser(url: string) {
export function resolveCommandExecutable(
cmd: string,
platform: NodeJS.Platform = process.platform,
): string | undefined {
if (!cmd) return undefined;
// If command already contains a path, only check that exact location.
if (cmd.includes('/') || cmd.includes('\\')) {
return fs.existsSync(cmd) ? cmd : undefined;
}
const pathValue = process.env.PATH || '';
if (!pathValue) return undefined;
const pathEntries = pathValue.split(path.delimiter).filter(Boolean);
if (platform === 'win32') {
const pathext = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
const hasExtension = path.extname(cmd).length > 0;
const candidateNames = hasExtension ? [cmd] : [cmd, ...pathext.map((ext) => `${cmd}${ext}`)];
// Prefer PATH lookup, then fall back to System32 for built-in tools like rundll32.
const systemRoot = process.env.SystemRoot || process.env.WINDIR;
if (systemRoot) {
pathEntries.push(path.join(systemRoot, 'System32'));
}
for (const entry of pathEntries) {
for (const candidate of candidateNames) {
const resolved = path.join(entry, candidate);
if (fs.existsSync(resolved)) return resolved;
}
}
return undefined;
}
for (const entry of pathEntries) {
const resolved = path.join(entry, cmd);
if (fs.existsSync(resolved)) return resolved;
}
return undefined;
}
async function openBrowser(url: string): Promise<boolean> {
const runCommand = (cmd: string, args: string[]) =>
new Promise<boolean>((resolve) => {
const executable = resolveCommandExecutable(cmd);
if (!executable) {
log.debug(`Could not open browser automatically: command not found in PATH: ${cmd}`);
resolve(false);
return;
}
try {
execFile(executable, args, (err) => {
if (err) {
log.debug(`Could not open browser automatically: ${err.message}`);
resolve(false);
return;
}
resolve(true);
});
} catch (error: any) {
log.debug(`Could not open browser automatically: ${error?.message || String(error)}`);
resolve(false);
}
});
if (process.platform === 'win32') {
// On Windows, use rundll32 to invoke the default URL handler without a shell.
execFile('rundll32', ['url.dll,FileProtocolHandler', url], (err) => {
if (err) {
log.debug(`Could not open browser automatically: ${err.message}`);
}
});
} else {
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
execFile(cmd, [url], (err) => {
if (err) {
log.debug(`Could not open browser automatically: ${err.message}`);
}
});
return runCommand('rundll32', ['url.dll,FileProtocolHandler', url]);
}
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
return runCommand(cmd, [url]);
}

View File

@@ -188,6 +188,7 @@ export function defineConfig() {
// Make only the consent view public (GET page), not other oauth paths
'/oauth/consent/(.*)',
'/oidc/handoff',
'/oidc/device/auth',
'/oidc/token',
// market
'/market-auth-callback',