mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
🐛 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:
@@ -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'",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user