mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
🐛 fix(cli): require gateway for custom server (#12856)
* 🐛 fix(cli): require --gateway for custom server logins * 🐛 fix(cli): persist custom server gateway settings * ♻️ refactor(cli): centralize official endpoint urls
This commit is contained in:
@@ -4,6 +4,8 @@ import superjson from 'superjson';
|
||||
import type { LambdaRouter } from '@/server/routers/lambda';
|
||||
|
||||
import { getValidToken } from '../auth/refresh';
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { loadSettings } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export type TrpcClient = ReturnType<typeof createTRPCClient<LambdaRouter>>;
|
||||
@@ -19,7 +21,8 @@ export async function getTrpcClient(): Promise<TrpcClient> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { serverUrl, accessToken } = result.credentials;
|
||||
const accessToken = result.credentials.accessToken;
|
||||
const serverUrl = loadSettings()?.serverUrl || OFFICIAL_SERVER_URL;
|
||||
|
||||
_client = createTRPCClient<LambdaRouter>({
|
||||
links: [
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getValidToken } from '../auth/refresh';
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { loadSettings } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
// Must match the server's SECRET_XOR_KEY (src/envs/auth.ts)
|
||||
@@ -35,7 +37,8 @@ export async function getAuthInfo(): Promise<AuthInfo> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { serverUrl, accessToken } = result!.credentials;
|
||||
const accessToken = result!.credentials.accessToken;
|
||||
const serverUrl = loadSettings()?.serverUrl || OFFICIAL_SERVER_URL;
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
|
||||
@@ -40,7 +40,6 @@ describe('credentials', () => {
|
||||
accessToken: 'test-access-token',
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
||||
refreshToken: 'test-refresh-token',
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
};
|
||||
|
||||
describe('saveCredentials + loadCredentials', () => {
|
||||
@@ -73,7 +72,6 @@ describe('credentials', () => {
|
||||
it('should handle credentials without optional fields', () => {
|
||||
const minimal: StoredCredentials = {
|
||||
accessToken: 'tok',
|
||||
serverUrl: 'https://test.com',
|
||||
};
|
||||
|
||||
saveCredentials(minimal);
|
||||
|
||||
@@ -7,7 +7,6 @@ export interface StoredCredentials {
|
||||
accessToken: string;
|
||||
expiresAt?: number; // Unix timestamp (seconds)
|
||||
refreshToken?: string;
|
||||
serverUrl: string;
|
||||
}
|
||||
|
||||
const CREDENTIALS_DIR = path.join(os.homedir(), '.lobehub');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { loadSettings } from '../settings';
|
||||
import type { StoredCredentials } from './credentials';
|
||||
import { loadCredentials, saveCredentials } from './credentials';
|
||||
import { getValidToken } from './refresh';
|
||||
@@ -8,6 +9,9 @@ vi.mock('./credentials', () => ({
|
||||
loadCredentials: vi.fn(),
|
||||
saveCredentials: vi.fn(),
|
||||
}));
|
||||
vi.mock('../settings', () => ({
|
||||
loadSettings: vi.fn().mockReturnValue({ serverUrl: 'https://app.lobehub.com' }),
|
||||
}));
|
||||
|
||||
describe('getValidToken', () => {
|
||||
beforeEach(() => {
|
||||
@@ -31,7 +35,6 @@ describe('getValidToken', () => {
|
||||
accessToken: 'valid-token',
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
|
||||
refreshToken: 'refresh-tok',
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
};
|
||||
vi.mocked(loadCredentials).mockReturnValue(creds);
|
||||
|
||||
@@ -44,7 +47,6 @@ describe('getValidToken', () => {
|
||||
it('should return credentials when no expiresAt is set', async () => {
|
||||
const creds: StoredCredentials = {
|
||||
accessToken: 'valid-token',
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
};
|
||||
vi.mocked(loadCredentials).mockReturnValue(creds);
|
||||
|
||||
@@ -59,7 +61,6 @@ describe('getValidToken', () => {
|
||||
const creds: StoredCredentials = {
|
||||
accessToken: 'expired-token',
|
||||
expiresAt: Math.floor(Date.now() / 1000) - 100, // expired
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
};
|
||||
vi.mocked(loadCredentials).mockReturnValue(creds);
|
||||
|
||||
@@ -73,7 +74,6 @@ describe('getValidToken', () => {
|
||||
accessToken: 'expired-token',
|
||||
expiresAt: Math.floor(Date.now() / 1000) - 100,
|
||||
refreshToken: 'valid-refresh-token',
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
};
|
||||
vi.mocked(loadCredentials).mockReturnValue(creds);
|
||||
|
||||
@@ -102,7 +102,6 @@ describe('getValidToken', () => {
|
||||
accessToken: 'expired-token',
|
||||
expiresAt: Math.floor(Date.now() / 1000) - 100,
|
||||
refreshToken: 'old-refresh-token',
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
};
|
||||
vi.mocked(loadCredentials).mockReturnValue(creds);
|
||||
|
||||
@@ -125,7 +124,6 @@ describe('getValidToken', () => {
|
||||
accessToken: 'expired-token',
|
||||
expiresAt: Math.floor(Date.now() / 1000) - 100,
|
||||
refreshToken: 'valid-refresh-token',
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
};
|
||||
vi.mocked(loadCredentials).mockReturnValue(creds);
|
||||
|
||||
@@ -145,7 +143,6 @@ describe('getValidToken', () => {
|
||||
accessToken: 'expired-token',
|
||||
expiresAt: Math.floor(Date.now() / 1000) - 100,
|
||||
refreshToken: 'valid-refresh-token',
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
};
|
||||
vi.mocked(loadCredentials).mockReturnValue(creds);
|
||||
|
||||
@@ -164,7 +161,6 @@ describe('getValidToken', () => {
|
||||
accessToken: 'expired-token',
|
||||
expiresAt: Math.floor(Date.now() / 1000) - 100,
|
||||
refreshToken: 'valid-refresh-token',
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
};
|
||||
vi.mocked(loadCredentials).mockReturnValue(creds);
|
||||
|
||||
@@ -183,7 +179,6 @@ describe('getValidToken', () => {
|
||||
accessToken: 'expired-token',
|
||||
expiresAt: Math.floor(Date.now() / 1000) - 100,
|
||||
refreshToken: 'valid-refresh-token',
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
};
|
||||
vi.mocked(loadCredentials).mockReturnValue(creds);
|
||||
|
||||
@@ -199,9 +194,9 @@ describe('getValidToken', () => {
|
||||
accessToken: 'expired-token',
|
||||
expiresAt: Math.floor(Date.now() / 1000) - 100,
|
||||
refreshToken: 'my-refresh-token',
|
||||
serverUrl: 'https://my-server.com',
|
||||
};
|
||||
vi.mocked(loadCredentials).mockReturnValue(creds);
|
||||
vi.mocked(loadSettings).mockReturnValueOnce({ serverUrl: 'https://my-server.com' });
|
||||
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { loadSettings } from '../settings';
|
||||
import { loadCredentials, saveCredentials, type StoredCredentials } from './credentials';
|
||||
|
||||
const CLIENT_ID = 'lobehub-cli';
|
||||
@@ -18,7 +20,8 @@ export async function getValidToken(): Promise<{ credentials: StoredCredentials
|
||||
// Token expired — try refresh
|
||||
if (!credentials.refreshToken) return null;
|
||||
|
||||
const refreshed = await refreshAccessToken(credentials.serverUrl, credentials.refreshToken);
|
||||
const serverUrl = loadSettings()?.serverUrl || OFFICIAL_SERVER_URL;
|
||||
const refreshed = await refreshAccessToken(serverUrl, credentials.refreshToken);
|
||||
if (!refreshed) return null;
|
||||
|
||||
const updated: StoredCredentials = {
|
||||
@@ -27,7 +30,6 @@ export async function getValidToken(): Promise<{ credentials: StoredCredentials
|
||||
? Math.floor(Date.now() / 1000) + refreshed.expires_in
|
||||
: undefined,
|
||||
refreshToken: refreshed.refresh_token || credentials.refreshToken,
|
||||
serverUrl: credentials.serverUrl,
|
||||
};
|
||||
|
||||
saveCredentials(updated);
|
||||
|
||||
@@ -82,7 +82,6 @@ describe('resolveToken', () => {
|
||||
vi.mocked(getValidToken).mockResolvedValue({
|
||||
credentials: {
|
||||
accessToken: token,
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -99,7 +98,6 @@ describe('resolveToken', () => {
|
||||
vi.mocked(getValidToken).mockResolvedValue({
|
||||
credentials: {
|
||||
accessToken: token,
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
vi.mock('../auth/resolveToken', () => ({
|
||||
resolveToken: vi.fn().mockResolvedValue({ token: 'test-token', userId: 'test-user' }),
|
||||
}));
|
||||
vi.mock('../settings', () => ({
|
||||
loadSettings: vi.fn().mockReturnValue(null),
|
||||
saveSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: {
|
||||
@@ -53,11 +57,13 @@ vi.mock('../tools', () => ({
|
||||
}));
|
||||
|
||||
let clientEventHandlers: Record<string, (...args: any[]) => any> = {};
|
||||
let clientOptions: any = {};
|
||||
let connectCalled = false;
|
||||
let lastSentToolResponse: any = null;
|
||||
let lastSentSystemInfoResponse: any = null;
|
||||
vi.mock('@lobechat/device-gateway-client', () => ({
|
||||
GatewayClient: vi.fn().mockImplementation(() => {
|
||||
GatewayClient: vi.fn().mockImplementation((opts: any) => {
|
||||
clientOptions = opts;
|
||||
clientEventHandlers = {};
|
||||
connectCalled = false;
|
||||
lastSentToolResponse = null;
|
||||
@@ -86,6 +92,8 @@ import { resolveToken } from '../auth/resolveToken';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { spawnDaemon, stopDaemon } from '../daemon/manager';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { executeToolCall } from '../tools';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { cleanupAllProcesses } from '../tools/shell';
|
||||
@@ -124,6 +132,36 @@ describe('connect command', () => {
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('LobeHub CLI'));
|
||||
});
|
||||
|
||||
it('should require explicit gateway for custom login server', async () => {
|
||||
vi.mocked(loadSettings).mockReturnValueOnce({ serverUrl: 'https://self-hosted.example.com' });
|
||||
|
||||
const program = createProgram();
|
||||
await expect(program.parseAsync(['node', 'test', 'connect'])).rejects.toThrow('process.exit');
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
"Current login uses custom --server https://self-hosted.example.com. Please also provide '--gateway <url>' for the device gateway.",
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should use explicit gateway for custom login server', async () => {
|
||||
vi.mocked(loadSettings).mockReturnValueOnce({ serverUrl: 'https://self-hosted.example.com' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'connect',
|
||||
'--gateway',
|
||||
'https://gateway.example.com/',
|
||||
]);
|
||||
|
||||
expect(clientOptions.gatewayUrl).toBe('https://gateway.example.com');
|
||||
expect(saveSettings).toHaveBeenCalledWith({
|
||||
gatewayUrl: 'https://gateway.example.com',
|
||||
serverUrl: 'https://self-hosted.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool call requests', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
|
||||
import {
|
||||
appendLog,
|
||||
getLogPath,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
stopDaemon,
|
||||
writeStatus,
|
||||
} from '../daemon/manager';
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
import { executeToolCall } from '../tools';
|
||||
import { cleanupAllProcesses } from '../tools/shell';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
@@ -40,7 +42,7 @@ export function registerConnectCommand(program: Command) {
|
||||
.command('connect')
|
||||
.description('Connect to the device gateway and listen for tool calls')
|
||||
.option('--token <jwt>', 'JWT access token')
|
||||
.option('--gateway <url>', 'Gateway URL', 'https://device-gateway.lobehub.com')
|
||||
.option('--gateway <url>', 'Device gateway URL')
|
||||
.option('--device-id <id>', 'Device ID (auto-generated if not provided)')
|
||||
.option('-v, --verbose', 'Enable verbose logging')
|
||||
.option('-d, --daemon', 'Run as a background daemon process')
|
||||
@@ -124,7 +126,7 @@ export function registerConnectCommand(program: Command) {
|
||||
.command('restart')
|
||||
.description('Restart the background daemon process')
|
||||
.option('--token <jwt>', 'JWT access token')
|
||||
.option('--gateway <url>', 'Gateway URL', 'https://device-gateway.lobehub.com')
|
||||
.option('--gateway <url>', 'Device gateway URL')
|
||||
.option('--device-id <id>', 'Device ID')
|
||||
.option('-v, --verbose', 'Enable verbose logging')
|
||||
.action((options: ConnectOptions) => {
|
||||
@@ -171,11 +173,26 @@ function buildDaemonArgs(options: ConnectOptions): string[] {
|
||||
|
||||
async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
const auth = await resolveToken(options);
|
||||
const gatewayUrl = options.gateway || 'https://device-gateway.lobehub.com';
|
||||
const settings = loadSettings();
|
||||
const gatewayUrl = options.gateway?.replace(/\/$/, '') || settings?.gatewayUrl;
|
||||
|
||||
if (!gatewayUrl && settings?.serverUrl) {
|
||||
log.error(
|
||||
`Current login uses custom --server ${settings?.serverUrl}. Please also provide '--gateway <url>' for the device gateway.`,
|
||||
);
|
||||
process.exit(1);
|
||||
throw new Error('process.exit');
|
||||
}
|
||||
|
||||
if (options.gateway && gatewayUrl) {
|
||||
saveSettings({ ...settings, gatewayUrl });
|
||||
}
|
||||
|
||||
const resolvedGatewayUrl = gatewayUrl || OFFICIAL_GATEWAY_URL;
|
||||
|
||||
const client = new GatewayClient({
|
||||
deviceId: options.deviceId,
|
||||
gatewayUrl,
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
logger: isDaemonChild ? createDaemonLogger() : log,
|
||||
token: auth.token,
|
||||
userId: auth.userId,
|
||||
@@ -196,7 +213,7 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
info(` Device ID : ${client.currentDeviceId}`);
|
||||
info(` Hostname : ${os.hostname()}`);
|
||||
info(` Platform : ${process.platform}`);
|
||||
info(` Gateway : ${gatewayUrl}`);
|
||||
info(` Gateway : ${resolvedGatewayUrl}`);
|
||||
info(` Auth : jwt`);
|
||||
info(` Mode : ${isDaemonChild ? 'daemon' : 'foreground'}`);
|
||||
info('───────────────────');
|
||||
@@ -206,7 +223,7 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
if (isDaemonChild) {
|
||||
writeStatus({
|
||||
connectionStatus,
|
||||
gatewayUrl,
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
pid: process.pid,
|
||||
startedAt: startedAt.toISOString(),
|
||||
});
|
||||
|
||||
@@ -4,12 +4,17 @@ import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { saveCredentials } from '../auth/credentials';
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
import { registerLoginCommand, resolveCommandExecutable } from './login';
|
||||
|
||||
vi.mock('../auth/credentials', () => ({
|
||||
saveCredentials: vi.fn(),
|
||||
}));
|
||||
vi.mock('../settings', () => ({
|
||||
loadSettings: vi.fn().mockReturnValue(null),
|
||||
saveSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: {
|
||||
@@ -22,6 +27,10 @@ vi.mock('../utils/logger', () => ({
|
||||
|
||||
// Mock child_process to prevent browser opening
|
||||
vi.mock('node:child_process', () => ({
|
||||
default: {
|
||||
exec: vi.fn((_cmd: string, cb: any) => cb?.(null)),
|
||||
execFile: vi.fn((_cmd: string, _args: string[], cb: any) => cb?.(null)),
|
||||
},
|
||||
exec: vi.fn((_cmd: string, cb: any) => cb?.(null)),
|
||||
execFile: vi.fn((_cmd: string, _args: string[], cb: any) => cb?.(null)),
|
||||
}));
|
||||
@@ -36,6 +45,7 @@ describe('login command', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
vi.mocked(loadSettings).mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -114,12 +124,56 @@ describe('login command', () => {
|
||||
expect.objectContaining({
|
||||
accessToken: 'new-token',
|
||||
refreshToken: 'refresh-tok',
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
}),
|
||||
);
|
||||
expect(saveSettings).toHaveBeenCalledWith({ serverUrl: 'https://app.lobehub.com' });
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Login successful'));
|
||||
});
|
||||
|
||||
it('should persist custom server into settings', async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce(deviceAuthResponse())
|
||||
.mockResolvedValueOnce(tokenSuccessResponse());
|
||||
|
||||
const program = createProgram();
|
||||
await runLoginAndAdvanceTimers(program, ['--server', 'https://test.com/']);
|
||||
|
||||
expect(saveSettings).toHaveBeenCalledWith({ serverUrl: 'https://test.com' });
|
||||
});
|
||||
|
||||
it('should preserve existing gateway when logging into the same server', async () => {
|
||||
vi.mocked(loadSettings).mockReturnValueOnce({
|
||||
gatewayUrl: 'https://gateway.example.com',
|
||||
serverUrl: 'https://test.com',
|
||||
});
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce(deviceAuthResponse())
|
||||
.mockResolvedValueOnce(tokenSuccessResponse());
|
||||
|
||||
const program = createProgram();
|
||||
await runLoginAndAdvanceTimers(program, ['--server', 'https://test.com/']);
|
||||
|
||||
expect(saveSettings).toHaveBeenCalledWith({
|
||||
gatewayUrl: 'https://gateway.example.com',
|
||||
serverUrl: 'https://test.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear existing gateway when logging into a different server', async () => {
|
||||
vi.mocked(loadSettings).mockReturnValueOnce({
|
||||
gatewayUrl: 'https://gateway.example.com',
|
||||
serverUrl: 'https://old.example.com',
|
||||
});
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce(deviceAuthResponse())
|
||||
.mockResolvedValueOnce(tokenSuccessResponse());
|
||||
|
||||
const program = createProgram();
|
||||
await runLoginAndAdvanceTimers(program, ['--server', 'https://new.example.com/']);
|
||||
|
||||
expect(saveSettings).toHaveBeenCalledWith({ serverUrl: 'https://new.example.com' });
|
||||
});
|
||||
|
||||
it('should strip trailing slash from server URL', async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce(deviceAuthResponse())
|
||||
|
||||
@@ -5,6 +5,8 @@ import path from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { saveCredentials } from '../auth/credentials';
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
const CLIENT_ID = 'lobehub-cli';
|
||||
@@ -50,7 +52,7 @@ export function registerLoginCommand(program: Command) {
|
||||
program
|
||||
.command('login')
|
||||
.description('Log in to LobeHub via browser (Device Code Flow)')
|
||||
.option('--server <url>', 'LobeHub server URL', 'https://app.lobehub.com')
|
||||
.option('--server <url>', 'LobeHub server URL', OFFICIAL_SERVER_URL)
|
||||
.action(async (options: LoginOptions) => {
|
||||
const serverUrl = options.server.replace(/\/$/, '');
|
||||
|
||||
@@ -161,8 +163,22 @@ export function registerLoginCommand(program: Command) {
|
||||
? Math.floor(Date.now() / 1000) + body.expires_in
|
||||
: undefined,
|
||||
refreshToken: body.refresh_token,
|
||||
serverUrl,
|
||||
});
|
||||
const existingSettings = loadSettings();
|
||||
const shouldPreserveGateway = existingSettings?.serverUrl === serverUrl;
|
||||
|
||||
saveSettings(
|
||||
shouldPreserveGateway
|
||||
? {
|
||||
gatewayUrl: existingSettings.gatewayUrl,
|
||||
serverUrl,
|
||||
}
|
||||
: {
|
||||
// Gateway auth is tied to the login server's token issuer/JWKS.
|
||||
// When server changes, clear old gateway to avoid stale cross-environment config.
|
||||
serverUrl,
|
||||
},
|
||||
);
|
||||
|
||||
log.info('Login successful! Credentials saved.');
|
||||
return;
|
||||
@@ -195,22 +211,21 @@ export function resolveCommandExecutable(
|
||||
const pathValue = process.env.PATH || '';
|
||||
if (!pathValue) return undefined;
|
||||
|
||||
const pathEntries = pathValue.split(path.delimiter).filter(Boolean);
|
||||
|
||||
if (platform === 'win32') {
|
||||
const pathEntries = pathValue.split(';').filter(Boolean);
|
||||
const pathext = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
|
||||
const hasExtension = path.extname(cmd).length > 0;
|
||||
const hasExtension = path.win32.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'));
|
||||
pathEntries.push(path.win32.join(systemRoot, 'System32'));
|
||||
}
|
||||
|
||||
for (const entry of pathEntries) {
|
||||
for (const candidate of candidateNames) {
|
||||
const resolved = path.join(entry, candidate);
|
||||
const resolved = path.win32.join(entry, candidate);
|
||||
if (fs.existsSync(resolved)) return resolved;
|
||||
}
|
||||
}
|
||||
@@ -218,6 +233,7 @@ export function resolveCommandExecutable(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pathEntries = pathValue.split(path.delimiter).filter(Boolean);
|
||||
for (const entry of pathEntries) {
|
||||
const resolved = path.join(entry, cmd);
|
||||
if (fs.existsSync(resolved)) return resolved;
|
||||
|
||||
@@ -5,6 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
vi.mock('../auth/resolveToken', () => ({
|
||||
resolveToken: vi.fn().mockResolvedValue({ token: 'test-token', userId: 'test-user' }),
|
||||
}));
|
||||
vi.mock('../settings', () => ({
|
||||
loadSettings: vi.fn().mockReturnValue(null),
|
||||
saveSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: {
|
||||
@@ -38,6 +42,8 @@ vi.mock('@lobechat/device-gateway-client', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { log } from '../utils/logger';
|
||||
// eslint-disable-next-line import-x/first
|
||||
@@ -76,6 +82,40 @@ describe('status command', () => {
|
||||
expect(clientOptions.autoReconnect).toBe(false);
|
||||
});
|
||||
|
||||
it('should require explicit gateway for custom login server', async () => {
|
||||
vi.mocked(loadSettings).mockReturnValueOnce({ serverUrl: 'https://self-hosted.example.com' });
|
||||
|
||||
const program = createProgram();
|
||||
await expect(program.parseAsync(['node', 'test', 'status'])).rejects.toThrow('process.exit');
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
"Current login uses custom --server https://self-hosted.example.com. Please also provide '--gateway <url>' for the device gateway.",
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should use explicit gateway for custom login server', async () => {
|
||||
vi.mocked(loadSettings).mockReturnValueOnce({ serverUrl: 'https://self-hosted.example.com' });
|
||||
|
||||
const program = createProgram();
|
||||
const parsePromise = program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'status',
|
||||
'--gateway',
|
||||
'https://gateway.example.com/',
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
clientEventHandlers['connected']?.();
|
||||
|
||||
await parsePromise;
|
||||
expect(clientOptions.gatewayUrl).toBe('https://gateway.example.com');
|
||||
expect(saveSettings).toHaveBeenCalledWith({
|
||||
gatewayUrl: 'https://gateway.example.com',
|
||||
serverUrl: 'https://self-hosted.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should log CONNECTED on successful connection', async () => {
|
||||
const program = createProgram();
|
||||
const parsePromise = program.parseAsync(['node', 'test', 'status']);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
|
||||
interface StatusOptions {
|
||||
@@ -20,18 +22,33 @@ export function registerStatusCommand(program: Command) {
|
||||
.option('--token <jwt>', 'JWT access token')
|
||||
.option('--service-token <token>', 'Service token (requires --user-id)')
|
||||
.option('--user-id <id>', 'User ID (required with --service-token)')
|
||||
.option('--gateway <url>', 'Gateway URL', 'https://device-gateway.lobehub.com')
|
||||
.option('--gateway <url>', 'Device gateway URL')
|
||||
.option('--timeout <ms>', 'Connection timeout in ms', '10000')
|
||||
.option('-v, --verbose', 'Enable verbose logging')
|
||||
.action(async (options: StatusOptions) => {
|
||||
if (options.verbose) setVerbose(true);
|
||||
|
||||
const auth = await resolveToken(options);
|
||||
const settings = loadSettings();
|
||||
const gatewayUrl = options.gateway?.replace(/\/$/, '') || settings?.gatewayUrl;
|
||||
|
||||
if (!gatewayUrl && settings?.serverUrl) {
|
||||
log.error(
|
||||
`Current login uses custom --server ${settings?.serverUrl}. Please also provide '--gateway <url>' for the device gateway.`,
|
||||
);
|
||||
process.exit(1);
|
||||
throw new Error('process.exit');
|
||||
}
|
||||
|
||||
if (options.gateway && gatewayUrl) {
|
||||
saveSettings({ ...settings, gatewayUrl });
|
||||
}
|
||||
|
||||
const timeout = Number.parseInt(options.timeout || '10000', 10);
|
||||
|
||||
const client = new GatewayClient({
|
||||
autoReconnect: false,
|
||||
gatewayUrl: options.gateway,
|
||||
gatewayUrl: gatewayUrl || OFFICIAL_GATEWAY_URL,
|
||||
logger: log,
|
||||
token: auth.token,
|
||||
userId: auth.userId,
|
||||
|
||||
2
apps/cli/src/constants/urls.ts
Normal file
2
apps/cli/src/constants/urls.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const OFFICIAL_SERVER_URL = 'https://app.lobehub.com';
|
||||
export const OFFICIAL_GATEWAY_URL = 'https://device-gateway.lobehub.com';
|
||||
67
apps/cli/src/settings/index.test.ts
Normal file
67
apps/cli/src/settings/index.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { log } from '../utils/logger';
|
||||
import { loadSettings, saveSettings } from './index';
|
||||
|
||||
const tmpDir = path.join(os.tmpdir(), 'lobehub-cli-test-settings');
|
||||
const settingsDir = path.join(tmpDir, '.lobehub');
|
||||
const settingsFile = path.join(settingsDir, 'settings.json');
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, any>>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual['default'],
|
||||
homedir: () => path.join(os.tmpdir(), 'lobehub-cli-test-settings'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: {
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('settings', () => {
|
||||
beforeEach(() => {
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { force: true, recursive: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should save and load custom server and gateway settings', () => {
|
||||
saveSettings({
|
||||
gatewayUrl: 'https://gateway.example.com/',
|
||||
serverUrl: 'https://self-hosted.example.com/',
|
||||
});
|
||||
|
||||
expect(loadSettings()).toEqual({
|
||||
gatewayUrl: 'https://gateway.example.com',
|
||||
serverUrl: 'https://self-hosted.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear official server settings instead of persisting them', () => {
|
||||
saveSettings({ serverUrl: 'https://app.lobehub.com/' });
|
||||
|
||||
expect(fs.existsSync(settingsFile)).toBe(false);
|
||||
expect(loadSettings()).toBeNull();
|
||||
});
|
||||
|
||||
it('should warn when settings file exists but cannot be parsed', () => {
|
||||
fs.mkdirSync(settingsDir, { recursive: true });
|
||||
fs.writeFileSync(settingsFile, '{invalid json');
|
||||
|
||||
expect(loadSettings()).toBeNull();
|
||||
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Please delete this file'));
|
||||
});
|
||||
});
|
||||
61
apps/cli/src/settings/index.ts
Normal file
61
apps/cli/src/settings/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export interface StoredSettings {
|
||||
gatewayUrl?: string;
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
const SETTINGS_DIR = path.join(os.homedir(), '.lobehub');
|
||||
const SETTINGS_FILE = path.join(SETTINGS_DIR, 'settings.json');
|
||||
|
||||
function normalizeUrl(url: string | undefined): string | undefined {
|
||||
return url ? url.replace(/\/$/, '') : undefined;
|
||||
}
|
||||
|
||||
export function saveSettings(settings: StoredSettings): void {
|
||||
const serverUrl = normalizeUrl(settings.serverUrl);
|
||||
const gatewayUrl = normalizeUrl(settings.gatewayUrl);
|
||||
const normalized: StoredSettings = {
|
||||
gatewayUrl,
|
||||
serverUrl: serverUrl === OFFICIAL_SERVER_URL ? undefined : serverUrl,
|
||||
};
|
||||
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl) {
|
||||
try {
|
||||
fs.unlinkSync(SETTINGS_FILE);
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(SETTINGS_DIR, { mode: 0o700, recursive: true });
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(normalized, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
export function loadSettings(): StoredSettings | null {
|
||||
if (!fs.existsSync(SETTINGS_FILE)) return null;
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
|
||||
const parsed = JSON.parse(data) as StoredSettings;
|
||||
const gatewayUrl = normalizeUrl(parsed.gatewayUrl);
|
||||
const serverUrl = normalizeUrl(parsed.serverUrl);
|
||||
const normalized: StoredSettings = {
|
||||
gatewayUrl,
|
||||
serverUrl: serverUrl === OFFICIAL_SERVER_URL ? undefined : serverUrl,
|
||||
};
|
||||
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl) return null;
|
||||
|
||||
return normalized;
|
||||
} catch {
|
||||
log.warn(
|
||||
`Could not parse ${SETTINGS_FILE}. Please delete this file and run 'lh login' again if needed.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user