🐛 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:
Rylan Cai
2026-03-10 00:02:51 +08:00
committed by GitHub
parent 473bc4e005
commit 3894facf5f
16 changed files with 346 additions and 36 deletions

View File

@@ -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: [

View File

@@ -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,

View File

@@ -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);

View File

@@ -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');

View File

@@ -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({

View File

@@ -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);

View File

@@ -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',
},
});

View File

@@ -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']);

View File

@@ -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(),
});

View File

@@ -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())

View File

@@ -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;

View File

@@ -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']);

View File

@@ -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,

View File

@@ -0,0 +1,2 @@
export const OFFICIAL_SERVER_URL = 'https://app.lobehub.com';
export const OFFICIAL_GATEWAY_URL = 'https://device-gateway.lobehub.com';

View 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'));
});
});

View 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;
}
}