feat: init lobehub-cli (#12735)

* init cli project

* Potential fix for code scanning alert no. 184: Uncontrolled command line

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* update

* Potential fix for code scanning alert no. 185: Uncontrolled command line

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Arvin Xu
2026-03-06 11:42:29 +08:00
committed by GitHub
parent 616d53e2ec
commit 76a07d811b
40 changed files with 5382 additions and 86 deletions

25
apps/cli/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@lobehub/cli",
"version": "0.0.1-canary.1",
"private": true,
"bin": {
"lh": "./src/index.ts"
},
"scripts": {
"test": "bunx vitest run --config vitest.config.mts --silent='passed-only'",
"test:coverage": "bunx vitest run --config vitest.config.mts --coverage",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@lobechat/device-gateway-client": "workspace:*",
"commander": "^13.1.0",
"diff": "^7.0.0",
"fast-glob": "^3.3.3",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/diff": "^6.0.0",
"@types/node": "^22.13.5",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,132 @@
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 {
clearCredentials,
loadCredentials,
saveCredentials,
type StoredCredentials,
} from './credentials';
// Use a fixed temp path to avoid hoisting issues with vi.mock
const tmpDir = path.join(os.tmpdir(), 'lobehub-cli-test-creds');
const credentialsDir = path.join(tmpDir, '.lobehub');
const credentialsFile = path.join(credentialsDir, 'credentials.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-creds'),
},
};
});
describe('credentials', () => {
beforeEach(() => {
fs.mkdirSync(tmpDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { force: true, recursive: true });
});
const testCredentials: StoredCredentials = {
accessToken: 'test-access-token',
expiresAt: Math.floor(Date.now() / 1000) + 3600,
refreshToken: 'test-refresh-token',
serverUrl: 'https://app.lobehub.com',
};
describe('saveCredentials + loadCredentials', () => {
it('should save and load credentials successfully', () => {
saveCredentials(testCredentials);
const loaded = loadCredentials();
expect(loaded).toEqual(testCredentials);
});
it('should create directory with correct permissions', () => {
saveCredentials(testCredentials);
expect(fs.existsSync(credentialsDir)).toBe(true);
});
it('should encrypt the credentials file', () => {
saveCredentials(testCredentials);
const raw = fs.readFileSync(credentialsFile, 'utf8');
// Should not be plain JSON
expect(() => JSON.parse(raw)).toThrow();
// Should be base64
expect(Buffer.from(raw, 'base64').length).toBeGreaterThan(0);
});
it('should handle credentials without optional fields', () => {
const minimal: StoredCredentials = {
accessToken: 'tok',
serverUrl: 'https://test.com',
};
saveCredentials(minimal);
const loaded = loadCredentials();
expect(loaded).toEqual(minimal);
});
});
describe('loadCredentials', () => {
it('should return null when no credentials file exists', () => {
const result = loadCredentials();
expect(result).toBeNull();
});
it('should handle legacy plaintext JSON and re-encrypt', () => {
fs.mkdirSync(credentialsDir, { recursive: true });
fs.writeFileSync(credentialsFile, JSON.stringify(testCredentials));
const loaded = loadCredentials();
expect(loaded).toEqual(testCredentials);
// Should have been re-encrypted
const raw = fs.readFileSync(credentialsFile, 'utf8');
expect(() => JSON.parse(raw)).toThrow();
});
it('should return null for corrupted file', () => {
fs.mkdirSync(credentialsDir, { recursive: true });
fs.writeFileSync(credentialsFile, 'not-valid-base64-or-json!!!');
const result = loadCredentials();
expect(result).toBeNull();
});
});
describe('clearCredentials', () => {
it('should remove credentials file and return true', () => {
saveCredentials(testCredentials);
const result = clearCredentials();
expect(result).toBe(true);
expect(fs.existsSync(credentialsFile)).toBe(false);
});
it('should return false when no file exists', () => {
const result = clearCredentials();
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,77 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
export interface StoredCredentials {
accessToken: string;
expiresAt?: number; // Unix timestamp (seconds)
refreshToken?: string;
serverUrl: string;
}
const CREDENTIALS_DIR = path.join(os.homedir(), '.lobehub');
const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json');
// Derive an encryption key from machine-specific info
// Not bulletproof, but prevents casual reading of the credentials file
function deriveKey(): Buffer {
const material = `lobehub-cli:${os.hostname()}:${os.userInfo().username}`;
return crypto.pbkdf2Sync(material, 'lobehub-cli-salt', 100_000, 32, 'sha256');
}
function encrypt(plaintext: string): string {
const key = deriveKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
// Pack: iv(12) + authTag(16) + ciphertext
const packed = Buffer.concat([iv, authTag, encrypted]);
return packed.toString('base64');
}
function decrypt(encoded: string): string {
const key = deriveKey();
const packed = Buffer.from(encoded, 'base64');
const iv = packed.subarray(0, 12);
const authTag = packed.subarray(12, 28);
const ciphertext = packed.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
return decipher.update(ciphertext) + decipher.final('utf8');
}
export function saveCredentials(credentials: StoredCredentials): void {
fs.mkdirSync(CREDENTIALS_DIR, { mode: 0o700, recursive: true });
const encrypted = encrypt(JSON.stringify(credentials));
fs.writeFileSync(CREDENTIALS_FILE, encrypted, { mode: 0o600 });
}
export function loadCredentials(): StoredCredentials | null {
try {
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
// Try decrypting first
try {
const decrypted = decrypt(data);
return JSON.parse(decrypted) as StoredCredentials;
} catch {
// Fallback: handle legacy plaintext JSON, re-save encrypted
const credentials = JSON.parse(data) as StoredCredentials;
saveCredentials(credentials);
return credentials;
}
} catch {
return null;
}
}
export function clearCredentials(): boolean {
try {
fs.unlinkSync(CREDENTIALS_FILE);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,229 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { StoredCredentials } from './credentials';
import { loadCredentials, saveCredentials } from './credentials';
import { getValidToken } from './refresh';
vi.mock('./credentials', () => ({
loadCredentials: vi.fn(),
saveCredentials: vi.fn(),
}));
describe('getValidToken', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return null when no credentials stored', async () => {
vi.mocked(loadCredentials).mockReturnValue(null);
const result = await getValidToken();
expect(result).toBeNull();
});
it('should return credentials when token is still valid', async () => {
const creds: StoredCredentials = {
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);
const result = await getValidToken();
expect(result).toEqual({ credentials: creds });
expect(fetch).not.toHaveBeenCalled();
});
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);
const result = await getValidToken();
// expiresAt is undefined, so Date.now()/1000 < undefined - 60 is false (NaN comparison)
// This means it will try to refresh, but there's no refreshToken
expect(result).toBeNull();
});
it('should return null when token expired and no refresh token', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100, // expired
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
const result = await getValidToken();
expect(result).toBeNull();
});
it('should refresh and save updated credentials when token is expired', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'valid-refresh-token',
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({
access_token: 'new-access-token',
expires_in: 3600,
refresh_token: 'new-refresh-token',
token_type: 'Bearer',
}),
ok: true,
} as any);
const result = await getValidToken();
expect(result).not.toBeNull();
expect(result!.credentials.accessToken).toBe('new-access-token');
expect(result!.credentials.refreshToken).toBe('new-refresh-token');
expect(saveCredentials).toHaveBeenCalledWith(
expect.objectContaining({ accessToken: 'new-access-token' }),
);
});
it('should keep old refresh token if new one is not returned', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'old-refresh-token',
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({
access_token: 'new-access-token',
token_type: 'Bearer',
}),
ok: true,
} as any);
const result = await getValidToken();
expect(result!.credentials.refreshToken).toBe('old-refresh-token');
expect(result!.credentials.expiresAt).toBeUndefined();
});
it('should return null when refresh request fails (non-ok)', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'valid-refresh-token',
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({}),
ok: false,
status: 401,
} as any);
const result = await getValidToken();
expect(result).toBeNull();
});
it('should return null when refresh response has error field', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'valid-refresh-token',
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ error: 'invalid_grant' }),
ok: true,
} as any);
const result = await getValidToken();
expect(result).toBeNull();
});
it('should return null when refresh response has no access_token', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'valid-refresh-token',
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ token_type: 'Bearer' }),
ok: true,
} as any);
const result = await getValidToken();
expect(result).toBeNull();
});
it('should return null when network error occurs during refresh', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'valid-refresh-token',
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockRejectedValue(new Error('network error'));
const result = await getValidToken();
expect(result).toBeNull();
});
it('should send correct request to refresh endpoint', async () => {
const creds: StoredCredentials = {
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(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({
access_token: 'new-token',
token_type: 'Bearer',
}),
ok: true,
} as any);
await getValidToken();
expect(fetch).toHaveBeenCalledWith(
'https://my-server.com/oidc/token',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}),
);
const body = vi.mocked(fetch).mock.calls[0][1]?.body as URLSearchParams;
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('my-refresh-token');
expect(body.get('client_id')).toBe('lobehub-cli');
});
});

View File

@@ -0,0 +1,67 @@
import { loadCredentials, saveCredentials, type StoredCredentials } from './credentials';
const CLIENT_ID = 'lobehub-cli';
/**
* Get a valid access token, refreshing if expired.
* Returns null if no credentials or refresh fails.
*/
export async function getValidToken(): Promise<{ credentials: StoredCredentials } | null> {
const credentials = loadCredentials();
if (!credentials) return null;
// Check if token is still valid (with 60s buffer)
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - 60) {
return { credentials };
}
// Token expired — try refresh
if (!credentials.refreshToken) return null;
const refreshed = await refreshAccessToken(credentials.serverUrl, credentials.refreshToken);
if (!refreshed) return null;
const updated: StoredCredentials = {
accessToken: refreshed.access_token,
expiresAt: refreshed.expires_in
? Math.floor(Date.now() / 1000) + refreshed.expires_in
: undefined,
refreshToken: refreshed.refresh_token || credentials.refreshToken,
serverUrl: credentials.serverUrl,
};
saveCredentials(updated);
return { credentials: updated };
}
interface TokenResponse {
access_token: string;
expires_in?: number;
refresh_token?: string;
token_type: string;
}
async function refreshAccessToken(
serverUrl: string,
refreshToken: string,
): Promise<TokenResponse | null> {
try {
const res = await fetch(`${serverUrl}/oidc/token`, {
body: new URLSearchParams({
client_id: CLIENT_ID,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: 'POST',
});
const body = (await res.json()) as TokenResponse & { error?: string };
if (!res.ok || body.error || !body.access_token) return null;
return body;
} catch {
return null;
}
}

View File

@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getValidToken } from './refresh';
import { resolveToken } from './resolveToken';
vi.mock('./refresh', () => ({
getValidToken: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
// Helper to create a valid JWT with sub claim
function makeJwt(sub: string): string {
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub })).toString('base64url');
return `${header}.${payload}.signature`;
}
describe('resolveToken', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
});
afterEach(() => {
exitSpy.mockRestore();
});
describe('with explicit --token', () => {
it('should return token and userId from JWT', async () => {
const token = makeJwt('user-123');
const result = await resolveToken({ token });
expect(result).toEqual({ token, userId: 'user-123' });
});
it('should exit if JWT has no sub claim', async () => {
const header = Buffer.from('{}').toString('base64url');
const payload = Buffer.from('{}').toString('base64url');
const token = `${header}.${payload}.sig`;
await expect(resolveToken({ token })).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should exit if JWT is malformed', async () => {
await expect(resolveToken({ token: 'not-a-jwt' })).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('with --service-token', () => {
it('should return token and userId', async () => {
const result = await resolveToken({
serviceToken: 'svc-token',
userId: 'user-456',
});
expect(result).toEqual({ token: 'svc-token', userId: 'user-456' });
});
it('should exit if --user-id is not provided', async () => {
await expect(resolveToken({ serviceToken: 'svc-token' })).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('with stored credentials', () => {
it('should return stored credentials token', async () => {
const token = makeJwt('stored-user');
vi.mocked(getValidToken).mockResolvedValue({
credentials: {
accessToken: token,
serverUrl: 'https://app.lobehub.com',
},
});
const result = await resolveToken({});
expect(result).toEqual({ token, userId: 'stored-user' });
});
it('should exit if stored token has no sub', async () => {
const header = Buffer.from('{}').toString('base64url');
const payload = Buffer.from('{}').toString('base64url');
const token = `${header}.${payload}.sig`;
vi.mocked(getValidToken).mockResolvedValue({
credentials: {
accessToken: token,
serverUrl: 'https://app.lobehub.com',
},
});
await expect(resolveToken({})).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should exit when no stored credentials', async () => {
vi.mocked(getValidToken).mockResolvedValue(null);
await expect(resolveToken({})).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
});

View File

@@ -0,0 +1,65 @@
import { log } from '../utils/logger';
import { getValidToken } from './refresh';
interface ResolveTokenOptions {
serviceToken?: string;
token?: string;
userId?: string;
}
interface ResolvedAuth {
token: string;
userId: string;
}
/**
* Parse the `sub` claim from a JWT without verifying the signature.
*/
function parseJwtSub(token: string): string | undefined {
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
return payload.sub;
} catch {
return undefined;
}
}
/**
* Resolve an access token from explicit options or stored credentials.
* Exits the process if no token can be resolved.
*/
export async function resolveToken(options: ResolveTokenOptions): Promise<ResolvedAuth> {
// Explicit token takes priority
if (options.token) {
const userId = parseJwtSub(options.token);
if (!userId) {
log.error('Could not extract userId from token. Provide --user-id explicitly.');
process.exit(1);
}
return { token: options.token, userId };
}
if (options.serviceToken) {
if (!options.userId) {
log.error('--user-id is required when using --service-token');
process.exit(1);
}
return { token: options.serviceToken, userId: options.userId };
}
// Try stored credentials
const result = await getValidToken();
if (result) {
log.debug('Using stored credentials');
const token = result.credentials.accessToken;
const userId = parseJwtSub(token);
if (!userId) {
log.error("Stored token is invalid. Run 'lh login' again.");
process.exit(1);
}
return { token, userId };
}
log.error("No authentication found. Run 'lh login' first, or provide --token.");
process.exit(1);
}

View File

@@ -0,0 +1,254 @@
import { Command } from 'commander';
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('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
toolCall: vi.fn(),
toolResult: vi.fn(),
warn: vi.fn(),
},
setVerbose: vi.fn(),
}));
vi.mock('../tools/shell', () => ({
cleanupAllProcesses: vi.fn(),
}));
vi.mock('../tools', () => ({
executeToolCall: vi.fn().mockResolvedValue({
content: 'tool result',
success: true,
}),
}));
let clientEventHandlers: Record<string, (...args: any[]) => any> = {};
let connectCalled = false;
let lastSentToolResponse: any = null;
let lastSentSystemInfoResponse: any = null;
vi.mock('@lobechat/device-gateway-client', () => ({
GatewayClient: vi.fn().mockImplementation(() => {
clientEventHandlers = {};
connectCalled = false;
lastSentToolResponse = null;
lastSentSystemInfoResponse = null;
return {
connect: vi.fn().mockImplementation(async () => {
connectCalled = true;
}),
currentDeviceId: 'mock-device-id',
disconnect: vi.fn(),
on: vi.fn().mockImplementation((event: string, handler: (...args: any[]) => any) => {
clientEventHandlers[event] = handler;
}),
sendSystemInfoResponse: vi.fn().mockImplementation((data: any) => {
lastSentSystemInfoResponse = data;
}),
sendToolCallResponse: vi.fn().mockImplementation((data: any) => {
lastSentToolResponse = data;
}),
};
}),
}));
// eslint-disable-next-line import-x/first
import { resolveToken } from '../auth/resolveToken';
// eslint-disable-next-line import-x/first
import { executeToolCall } from '../tools';
// eslint-disable-next-line import-x/first
import { cleanupAllProcesses } from '../tools/shell';
// eslint-disable-next-line import-x/first
import { log, setVerbose } from '../utils/logger';
// eslint-disable-next-line import-x/first
import { registerConnectCommand } from './connect';
describe('connect command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
});
afterEach(() => {
exitSpy.mockRestore();
vi.clearAllMocks();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerConnectCommand(program);
return program;
}
it('should connect to gateway', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
expect(connectCalled).toBe(true);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('LobeHub CLI'));
});
it('should handle tool call requests', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
// Trigger tool call
await clientEventHandlers['tool_call_request']?.({
requestId: 'req-1',
toolCall: { apiName: 'readLocalFile', arguments: '{"path":"/test"}', identifier: 'test' },
type: 'tool_call_request',
});
expect(executeToolCall).toHaveBeenCalledWith('readLocalFile', '{"path":"/test"}');
expect(lastSentToolResponse).toEqual({
requestId: 'req-1',
result: { content: 'tool result', error: undefined, success: true },
});
});
it('should handle system info requests', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
clientEventHandlers['system_info_request']?.({
requestId: 'req-2',
type: 'system_info_request',
});
expect(lastSentSystemInfoResponse).toBeDefined();
expect(lastSentSystemInfoResponse.requestId).toBe('req-2');
expect(lastSentSystemInfoResponse.result.success).toBe(true);
expect(lastSentSystemInfoResponse.result.systemInfo).toHaveProperty('homePath');
expect(lastSentSystemInfoResponse.result.systemInfo).toHaveProperty('arch');
});
it('should handle auth_failed', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
clientEventHandlers['auth_failed']?.('invalid token');
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Authentication failed'));
expect(cleanupAllProcesses).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle auth_expired', async () => {
vi.mocked(resolveToken).mockResolvedValueOnce({ token: 'new-tok', userId: 'user' });
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
await clientEventHandlers['auth_expired']?.();
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(cleanupAllProcesses).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle error event', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
clientEventHandlers['error']?.(new Error('connection lost'));
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('connection lost'));
});
it('should set verbose mode when -v flag is passed', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', '-v']);
expect(setVerbose).toHaveBeenCalledWith(true);
});
it('should show service-token auth type', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'connect',
'--service-token',
'svc-tok',
'--user-id',
'u1',
]);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('service-token'));
});
it('should handle SIGINT', async () => {
const sigintHandlers: Array<() => void> = [];
const origOn = process.on;
vi.spyOn(process, 'on').mockImplementation((event: any, handler: any) => {
if (event === 'SIGINT') sigintHandlers.push(handler);
return origOn.call(process, event, handler);
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
// Trigger SIGINT handler
for (const handler of sigintHandlers) {
handler();
}
expect(cleanupAllProcesses).toHaveBeenCalled();
});
it('should handle auth_expired when refresh fails', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
// After initial connect, mock resolveToken to return falsy for the refresh attempt
vi.mocked(resolveToken).mockResolvedValueOnce(undefined as any);
await clientEventHandlers['auth_expired']?.();
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Could not refresh'));
expect(cleanupAllProcesses).toHaveBeenCalled();
});
it('should handle SIGTERM', async () => {
const sigtermHandlers: Array<() => void> = [];
const origOn = process.on;
vi.spyOn(process, 'on').mockImplementation((event: any, handler: any) => {
if (event === 'SIGTERM') sigtermHandlers.push(handler);
return origOn.call(process, event, handler);
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
for (const handler of sigtermHandlers) {
handler();
}
expect(cleanupAllProcesses).toHaveBeenCalled();
});
it('should generate correct system info with Movies for non-linux', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
clientEventHandlers['system_info_request']?.({
requestId: 'req-3',
type: 'system_info_request',
});
const sysInfo = lastSentSystemInfoResponse.result.systemInfo;
// On macOS (darwin), video dir should be Movies
if (process.platform !== 'linux') {
expect(sysInfo.videosPath).toContain('Movies');
} else {
expect(sysInfo.videosPath).toContain('Videos');
}
});
});

View File

@@ -0,0 +1,153 @@
import os from 'node:os';
import path from 'node:path';
import type {
DeviceSystemInfo,
SystemInfoRequestMessage,
ToolCallRequestMessage,
} from '@lobechat/device-gateway-client';
import { GatewayClient } from '@lobechat/device-gateway-client';
import type { Command } from 'commander';
import { resolveToken } from '../auth/resolveToken';
import { executeToolCall } from '../tools';
import { cleanupAllProcesses } from '../tools/shell';
import { log, setVerbose } from '../utils/logger';
interface ConnectOptions {
deviceId?: string;
gateway?: string;
serviceToken?: string;
token?: string;
userId?: string;
verbose?: boolean;
}
export function registerConnectCommand(program: Command) {
program
.command('connect')
.description('Connect to the device gateway and listen for tool calls')
.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('--device-id <id>', 'Device ID (auto-generated if not provided)')
.option('-v, --verbose', 'Enable verbose logging')
.action(async (options: ConnectOptions) => {
if (options.verbose) setVerbose(true);
const auth = await resolveToken(options);
const client = new GatewayClient({
deviceId: options.deviceId,
gatewayUrl: options.gateway,
logger: log,
token: auth.token,
userId: auth.userId,
});
// Print device info
log.info('─── LobeHub CLI ───');
log.info(` Device ID : ${client.currentDeviceId}`);
log.info(` Hostname : ${os.hostname()}`);
log.info(` Platform : ${process.platform}`);
log.info(` Gateway : ${options.gateway || 'https://device-gateway.lobehub.com'}`);
log.info(` Auth : ${options.serviceToken ? 'service-token' : 'jwt'}`);
log.info('───────────────────');
// Handle system info requests
client.on('system_info_request', (request: SystemInfoRequestMessage) => {
log.info(`Received system_info_request: requestId=${request.requestId}`);
const systemInfo = collectSystemInfo();
client.sendSystemInfoResponse({
requestId: request.requestId,
result: { success: true, systemInfo },
});
});
// Handle tool call requests
client.on('tool_call_request', async (request: ToolCallRequestMessage) => {
const { requestId, toolCall } = request;
log.toolCall(toolCall.apiName, requestId, toolCall.arguments);
const result = await executeToolCall(toolCall.apiName, toolCall.arguments);
log.toolResult(requestId, result.success, result.content);
client.sendToolCallResponse({
requestId,
result: {
content: result.content,
error: result.error,
success: result.success,
},
});
});
// Handle auth failed
client.on('auth_failed', (reason) => {
log.error(`Authentication failed: ${reason}`);
log.error("Run 'lh login' to re-authenticate.");
cleanup();
process.exit(1);
});
// Handle auth expired — try refresh before giving up
client.on('auth_expired', async () => {
log.warn('Authentication expired. Attempting to refresh...');
const refreshed = await resolveToken({});
if (refreshed) {
log.info('Token refreshed. Please reconnect.');
} else {
log.error("Could not refresh token. Run 'lh login' to re-authenticate.");
}
cleanup();
process.exit(1);
});
// Handle errors
client.on('error', (error) => {
log.error(`Connection error: ${error.message}`);
});
// Graceful shutdown
const cleanup = () => {
log.info('Shutting down...');
cleanupAllProcesses();
client.disconnect();
};
process.on('SIGINT', () => {
cleanup();
process.exit(0);
});
process.on('SIGTERM', () => {
cleanup();
process.exit(0);
});
// Connect
await client.connect();
});
}
function collectSystemInfo(): DeviceSystemInfo {
const home = os.homedir();
const platform = process.platform;
// Platform-specific video path name
const videosDir = platform === 'linux' ? 'Videos' : 'Movies';
return {
arch: os.arch(),
desktopPath: path.join(home, 'Desktop'),
documentsPath: path.join(home, 'Documents'),
downloadsPath: path.join(home, 'Downloads'),
homePath: home,
musicPath: path.join(home, 'Music'),
picturesPath: path.join(home, 'Pictures'),
userDataPath: path.join(home, '.lobehub'),
videosPath: path.join(home, videosDir),
workingDirectory: process.cwd(),
};
}

View File

@@ -0,0 +1,250 @@
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';
vi.mock('../auth/credentials', () => ({
saveCredentials: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
// Mock child_process.exec to prevent browser opening
vi.mock('node:child_process', () => ({
exec: vi.fn((_cmd: string, cb: any) => cb?.(null)),
}));
describe('login command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn());
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
});
afterEach(() => {
vi.useRealTimers();
exitSpy.mockRestore();
vi.restoreAllMocks();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerLoginCommand(program);
return program;
}
function deviceAuthResponse(overrides: Record<string, any> = {}) {
return {
json: vi.fn().mockResolvedValue({
device_code: 'device-123',
expires_in: 600,
interval: 1,
user_code: 'USER-CODE',
verification_uri: 'https://app.lobehub.com/verify',
verification_uri_complete: 'https://app.lobehub.com/verify?code=USER-CODE',
...overrides,
}),
ok: true,
} as any;
}
function tokenSuccessResponse(overrides: Record<string, any> = {}) {
return {
json: vi.fn().mockResolvedValue({
access_token: 'new-token',
expires_in: 3600,
refresh_token: 'refresh-tok',
token_type: 'Bearer',
...overrides,
}),
ok: true,
} as any;
}
function tokenErrorResponse(error: string, description?: string) {
return {
json: vi.fn().mockResolvedValue({
error,
error_description: description,
}),
ok: true,
} as any;
}
async function runLoginAndAdvanceTimers(program: Command, args: string[] = []) {
const parsePromise = program.parseAsync(['node', 'test', 'login', ...args]);
// Advance timers to let sleep resolve in the polling loop
for (let i = 0; i < 10; i++) {
await vi.advanceTimersByTimeAsync(2000);
}
return parsePromise;
}
it('should complete login flow successfully', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockResolvedValueOnce(tokenErrorResponse('authorization_pending'))
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(saveCredentials).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: 'new-token',
refreshToken: 'refresh-tok',
serverUrl: 'https://app.lobehub.com',
}),
);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Login successful'));
});
it('should strip trailing slash from server URL', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program, ['--server', 'https://test.com/']);
expect(fetch).toHaveBeenCalledWith('https://test.com/oidc/device/auth', expect.any(Object));
});
it('should handle device auth failure', async () => {
// For early-exit tests, process.exit must throw to stop code execution
// (otherwise code continues past exit and accesses undefined deviceAuth)
exitSpy.mockImplementation(() => {
throw new Error('exit');
});
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue('Server Error'),
} as any);
const program = createProgram();
await runLoginAndAdvanceTimers(program).catch(() => {});
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to start'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle network error on device auth', async () => {
exitSpy.mockImplementation(() => {
throw new Error('exit');
});
vi.mocked(fetch).mockRejectedValueOnce(new Error('ECONNREFUSED'));
const program = createProgram();
await runLoginAndAdvanceTimers(program).catch(() => {});
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to reach'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle access_denied error', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse({ expires_in: 2 }))
.mockResolvedValueOnce(tokenErrorResponse('access_denied'));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('denied'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle expired_token error', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse({ expires_in: 2 }))
.mockResolvedValueOnce(tokenErrorResponse('expired_token'));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle slow_down by increasing interval', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockResolvedValueOnce(tokenErrorResponse('slow_down'))
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(saveCredentials).toHaveBeenCalled();
});
it('should handle unknown error', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse({ expires_in: 2 }))
.mockResolvedValueOnce(tokenErrorResponse('server_error', 'Something went wrong'));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('server_error'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle network error during polling', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(saveCredentials).toHaveBeenCalled();
});
it('should handle token without expires_in', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockResolvedValueOnce(tokenSuccessResponse({ expires_in: undefined }));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(saveCredentials).toHaveBeenCalledWith(expect.objectContaining({ expiresAt: undefined }));
});
it('should use default interval when not provided', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse({ interval: undefined }))
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(saveCredentials).toHaveBeenCalled();
});
it('should handle device code expiration during polling', async () => {
vi.mocked(fetch).mockResolvedValueOnce(deviceAuthResponse({ expires_in: 0 }));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});

View File

@@ -0,0 +1,178 @@
import { execFile } from 'node:child_process';
import type { Command } from 'commander';
import { saveCredentials } from '../auth/credentials';
import { log } from '../utils/logger';
const CLIENT_ID = 'lobehub-cli';
const SCOPES = 'openid profile email offline_access';
interface LoginOptions {
server: string;
}
interface DeviceAuthResponse {
device_code: string;
expires_in: number;
interval: number;
user_code: string;
verification_uri: string;
verification_uri_complete?: string;
}
interface TokenResponse {
access_token: string;
expires_in?: number;
refresh_token?: string;
token_type: string;
}
interface TokenErrorResponse {
error: string;
error_description?: string;
}
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')
.action(async (options: LoginOptions) => {
const serverUrl = options.server.replace(/\/$/, '');
log.info('Starting login...');
// Step 1: Request device code
let deviceAuth: DeviceAuthResponse;
try {
const res = await fetch(`${serverUrl}/oidc/device/auth`, {
body: new URLSearchParams({
client_id: CLIENT_ID,
resource: 'urn:lobehub:chat',
scope: SCOPES,
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: 'POST',
});
if (!res.ok) {
const text = await res.text();
log.error(`Failed to start device authorization: ${res.status} ${text}`);
process.exit(1);
}
deviceAuth = (await res.json()) as DeviceAuthResponse;
} catch (error: any) {
log.error(`Failed to reach server: ${error.message}`);
log.error(`Make sure ${serverUrl} is reachable.`);
process.exit(1);
}
// Step 2: Show user code and open browser
const verifyUrl = deviceAuth.verification_uri_complete || deviceAuth.verification_uri;
log.info('');
log.info(' Open this URL in your browser:');
log.info(` ${verifyUrl}`);
log.info('');
log.info(` Enter code: ${deviceAuth.user_code}`);
log.info('');
// Try to open browser automatically
openBrowser(verifyUrl);
log.info('Waiting for authorization...');
// Step 3: Poll for token
const interval = (deviceAuth.interval || 5) * 1000;
const expiresAt = Date.now() + deviceAuth.expires_in * 1000;
let pollInterval = interval;
while (Date.now() < expiresAt) {
await sleep(pollInterval);
try {
const res = await fetch(`${serverUrl}/oidc/token`, {
body: new URLSearchParams({
client_id: CLIENT_ID,
device_code: deviceAuth.device_code,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: 'POST',
});
const body = (await res.json()) as TokenResponse & TokenErrorResponse;
// Check body for error field — some proxies may return 200 for error responses
if (body.error) {
switch (body.error) {
case 'authorization_pending': {
// Keep polling
break;
}
case 'slow_down': {
pollInterval += 5000;
break;
}
case 'access_denied': {
log.error('Authorization denied by user.');
process.exit(1);
break;
}
case 'expired_token': {
log.error('Device code expired. Please run login again.');
process.exit(1);
break;
}
default: {
log.error(`Authorization error: ${body.error} - ${body.error_description || ''}`);
process.exit(1);
}
}
} else if (body.access_token) {
saveCredentials({
accessToken: body.access_token,
expiresAt: body.expires_in
? Math.floor(Date.now() / 1000) + body.expires_in
: undefined,
refreshToken: body.refresh_token,
serverUrl,
});
log.info('Login successful! Credentials saved.');
return;
}
} catch {
// Network error — keep retrying
}
}
log.error('Device code expired. Please run login again.');
process.exit(1);
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function openBrowser(url: string) {
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}`);
}
});
}
}

View File

@@ -0,0 +1,47 @@
import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';
import { clearCredentials } from '../auth/credentials';
import { log } from '../utils/logger';
import { registerLogoutCommand } from './logout';
vi.mock('../auth/credentials', () => ({
clearCredentials: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
describe('logout command', () => {
function createProgram() {
const program = new Command();
program.exitOverride();
registerLogoutCommand(program);
return program;
}
it('should log success when credentials are removed', async () => {
vi.mocked(clearCredentials).mockReturnValue(true);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(clearCredentials).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Logged out'));
});
it('should log already logged out when no credentials', async () => {
vi.mocked(clearCredentials).mockReturnValue(false);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Already logged out'));
});
});

View File

@@ -0,0 +1,18 @@
import type { Command } from 'commander';
import { clearCredentials } from '../auth/credentials';
import { log } from '../utils/logger';
export function registerLogoutCommand(program: Command) {
program
.command('logout')
.description('Log out and remove stored credentials')
.action(() => {
const removed = clearCredentials();
if (removed) {
log.info('Logged out. Credentials removed.');
} else {
log.info('No credentials found. Already logged out.');
}
});
}

View File

@@ -0,0 +1,164 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock resolveToken
vi.mock('../auth/resolveToken', () => ({
resolveToken: vi.fn().mockResolvedValue({ token: 'test-token', userId: 'test-user' }),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
setVerbose: vi.fn(),
}));
// Track event handlers registered on GatewayClient instances
let clientEventHandlers: Record<string, (...args: any[]) => any> = {};
let connectCalled = false;
let clientOptions: any = {};
vi.mock('@lobechat/device-gateway-client', () => ({
GatewayClient: vi.fn().mockImplementation((opts: any) => {
clientOptions = opts;
clientEventHandlers = {};
connectCalled = false;
return {
connect: vi.fn().mockImplementation(async () => {
connectCalled = true;
}),
disconnect: vi.fn(),
on: vi.fn().mockImplementation((event: string, handler: (...args: any[]) => any) => {
clientEventHandlers[event] = handler;
}),
};
}),
}));
// eslint-disable-next-line import-x/first
import { log } from '../utils/logger';
// eslint-disable-next-line import-x/first
import { registerStatusCommand } from './status';
describe('status command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
});
afterEach(() => {
vi.useRealTimers();
exitSpy.mockRestore();
vi.clearAllMocks();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerStatusCommand(program);
return program;
}
it('should create client with autoReconnect false', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
// Trigger connected to finish the command
clientEventHandlers['connected']?.();
await parsePromise;
expect(clientOptions.autoReconnect).toBe(false);
});
it('should log CONNECTED on successful connection', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['connected']?.();
await parsePromise;
expect(log.info).toHaveBeenCalledWith('CONNECTED');
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('should log FAILED on disconnected', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['disconnected']?.();
await parsePromise;
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('FAILED'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should log FAILED on auth_failed', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['auth_failed']?.('bad token');
await parsePromise;
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Authentication failed'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should log FAILED on auth_expired', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['auth_expired']?.();
await parsePromise;
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should log connection error', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['error']?.(new Error('network issue'));
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('network issue'));
// Clean up by triggering connected
clientEventHandlers['connected']?.();
await parsePromise;
});
it('should timeout if no connection within timeout period', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status', '--timeout', '5000']);
// Advance timer past timeout
await vi.advanceTimersByTimeAsync(5001);
await parsePromise;
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('timed out'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should call connect on the client', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
expect(connectCalled).toBe(true);
// Clean up
clientEventHandlers['connected']?.();
await parsePromise;
});
});

View File

@@ -0,0 +1,78 @@
import { GatewayClient } from '@lobechat/device-gateway-client';
import type { Command } from 'commander';
import { resolveToken } from '../auth/resolveToken';
import { log, setVerbose } from '../utils/logger';
interface StatusOptions {
gateway?: string;
serviceToken?: string;
timeout?: string;
token?: string;
userId?: string;
verbose?: boolean;
}
export function registerStatusCommand(program: Command) {
program
.command('status')
.description('Check if gateway connection can be established')
.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('--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 timeout = Number.parseInt(options.timeout || '10000', 10);
const client = new GatewayClient({
autoReconnect: false,
gatewayUrl: options.gateway,
logger: log,
token: auth.token,
userId: auth.userId,
});
const timer = setTimeout(() => {
log.error('FAILED - Connection timed out');
client.disconnect();
process.exit(1);
}, timeout);
client.on('connected', () => {
clearTimeout(timer);
log.info('CONNECTED');
client.disconnect();
process.exit(0);
});
client.on('disconnected', () => {
clearTimeout(timer);
log.error('FAILED - Connection closed by server');
process.exit(1);
});
client.on('auth_failed', (reason) => {
clearTimeout(timer);
log.error(`FAILED - Authentication failed: ${reason}`);
process.exit(1);
});
client.on('auth_expired', () => {
clearTimeout(timer);
log.error('FAILED - Authentication expired');
client.disconnect();
process.exit(1);
});
client.on('error', (error) => {
log.error(`Connection error: ${error.message}`);
});
await client.connect();
});
}

22
apps/cli/src/index.ts Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bun
import { Command } from 'commander';
import { registerConnectCommand } from './commands/connect';
import { registerLoginCommand } from './commands/login';
import { registerLogoutCommand } from './commands/logout';
import { registerStatusCommand } from './commands/status';
const program = new Command();
program
.name('lh')
.description('LobeHub CLI - manage and connect to LobeHub services')
.version('0.1.0');
registerLoginCommand(program);
registerLogoutCommand(program);
registerConnectCommand(program);
registerStatusCommand(program);
program.parse();

View File

@@ -0,0 +1,458 @@
import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
editLocalFile,
globLocalFiles,
grepContent,
listLocalFiles,
readLocalFile,
searchLocalFiles,
writeLocalFile,
} from './file';
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
describe('file tools', () => {
const tmpDir = path.join(os.tmpdir(), 'cli-file-test-' + process.pid);
beforeEach(async () => {
await mkdir(tmpDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { force: true, recursive: true });
});
describe('readLocalFile', () => {
it('should read a file with default line range (0-200)', async () => {
const filePath = path.join(tmpDir, 'test.txt');
const lines = Array.from({ length: 300 }, (_, i) => `line ${i}`);
await writeFile(filePath, lines.join('\n'));
const result = await readLocalFile({ path: filePath });
expect(result.lineCount).toBe(200);
expect(result.totalLineCount).toBe(300);
expect(result.loc).toEqual([0, 200]);
expect(result.filename).toBe('test.txt');
expect(result.fileType).toBe('txt');
});
it('should read full content when fullContent is true', async () => {
const filePath = path.join(tmpDir, 'full.txt');
const lines = Array.from({ length: 300 }, (_, i) => `line ${i}`);
await writeFile(filePath, lines.join('\n'));
const result = await readLocalFile({ fullContent: true, path: filePath });
expect(result.lineCount).toBe(300);
expect(result.loc).toEqual([0, 300]);
});
it('should read specific line range', async () => {
const filePath = path.join(tmpDir, 'range.txt');
const lines = Array.from({ length: 10 }, (_, i) => `line ${i}`);
await writeFile(filePath, lines.join('\n'));
const result = await readLocalFile({ loc: [2, 5], path: filePath });
expect(result.lineCount).toBe(3);
expect(result.content).toBe('line 2\nline 3\nline 4');
expect(result.loc).toEqual([2, 5]);
});
it('should handle non-existent file', async () => {
const result = await readLocalFile({ path: path.join(tmpDir, 'nope.txt') });
expect(result.content).toContain('Error');
expect(result.lineCount).toBe(0);
expect(result.totalLineCount).toBe(0);
});
it('should detect file type from extension', async () => {
const filePath = path.join(tmpDir, 'code.ts');
await writeFile(filePath, 'const x = 1;');
const result = await readLocalFile({ path: filePath });
expect(result.fileType).toBe('ts');
});
it('should handle file without extension', async () => {
const filePath = path.join(tmpDir, 'Makefile');
await writeFile(filePath, 'all: build');
const result = await readLocalFile({ path: filePath });
expect(result.fileType).toBe('unknown');
});
});
describe('writeLocalFile', () => {
it('should write a file successfully', async () => {
const filePath = path.join(tmpDir, 'output.txt');
const result = await writeLocalFile({ content: 'hello world', path: filePath });
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('hello world');
});
it('should create parent directories', async () => {
const filePath = path.join(tmpDir, 'sub', 'dir', 'file.txt');
const result = await writeLocalFile({ content: 'nested', path: filePath });
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('nested');
});
it('should return error for empty path', async () => {
const result = await writeLocalFile({ content: 'data', path: '' });
expect(result.success).toBe(false);
expect(result.error).toContain('Path cannot be empty');
});
it('should return error for undefined content', async () => {
const result = await writeLocalFile({
content: undefined as any,
path: path.join(tmpDir, 'f.txt'),
});
expect(result.success).toBe(false);
expect(result.error).toContain('Content cannot be empty');
});
});
describe('editLocalFile', () => {
it('should replace first occurrence by default', async () => {
const filePath = path.join(tmpDir, 'edit.txt');
await writeFile(filePath, 'hello world\nhello again');
const result = await editLocalFile({
file_path: filePath,
new_string: 'hi',
old_string: 'hello',
});
expect(result.success).toBe(true);
expect(result.replacements).toBe(1);
expect(fs.readFileSync(filePath, 'utf8')).toBe('hi world\nhello again');
expect(result.diffText).toBeDefined();
expect(result.linesAdded).toBeDefined();
expect(result.linesDeleted).toBeDefined();
});
it('should replace all occurrences when replace_all is true', async () => {
const filePath = path.join(tmpDir, 'edit-all.txt');
await writeFile(filePath, 'hello world\nhello again');
const result = await editLocalFile({
file_path: filePath,
new_string: 'hi',
old_string: 'hello',
replace_all: true,
});
expect(result.success).toBe(true);
expect(result.replacements).toBe(2);
expect(fs.readFileSync(filePath, 'utf8')).toBe('hi world\nhi again');
});
it('should return error when old_string not found', async () => {
const filePath = path.join(tmpDir, 'no-match.txt');
await writeFile(filePath, 'hello world');
const result = await editLocalFile({
file_path: filePath,
new_string: 'hi',
old_string: 'xyz',
});
expect(result.success).toBe(false);
expect(result.replacements).toBe(0);
});
it('should handle special regex characters in old_string with replace_all', async () => {
const filePath = path.join(tmpDir, 'regex.txt');
await writeFile(filePath, 'price is $10.00 and $20.00');
const result = await editLocalFile({
file_path: filePath,
new_string: '$XX.XX',
old_string: '$10.00',
replace_all: true,
});
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('price is $XX.XX and $20.00');
});
it('should handle file read error', async () => {
const result = await editLocalFile({
file_path: path.join(tmpDir, 'nonexistent.txt'),
new_string: 'new',
old_string: 'old',
});
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('listLocalFiles', () => {
it('should list files in directory', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
await mkdir(path.join(tmpDir, 'subdir'));
const result = await listLocalFiles({ path: tmpDir });
expect(result.totalCount).toBe(3);
expect(result.files.length).toBe(3);
const names = result.files.map((f: any) => f.name);
expect(names).toContain('a.txt');
expect(names).toContain('b.txt');
expect(names).toContain('subdir');
});
it('should sort by name ascending', async () => {
await writeFile(path.join(tmpDir, 'c.txt'), 'c');
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
const result = await listLocalFiles({
path: tmpDir,
sortBy: 'name',
sortOrder: 'asc',
});
expect(result.files[0].name).toBe('a.txt');
expect(result.files[2].name).toBe('c.txt');
});
it('should sort by size', async () => {
await writeFile(path.join(tmpDir, 'small.txt'), 'x');
await writeFile(path.join(tmpDir, 'large.txt'), 'x'.repeat(1000));
const result = await listLocalFiles({
path: tmpDir,
sortBy: 'size',
sortOrder: 'asc',
});
expect(result.files[0].name).toBe('small.txt');
});
it('should sort by createdTime', async () => {
await writeFile(path.join(tmpDir, 'first.txt'), 'first');
// Small delay to ensure different timestamps
await new Promise((r) => setTimeout(r, 10));
await writeFile(path.join(tmpDir, 'second.txt'), 'second');
const result = await listLocalFiles({
path: tmpDir,
sortBy: 'createdTime',
sortOrder: 'asc',
});
expect(result.files.length).toBe(2);
});
it('should respect limit', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
await writeFile(path.join(tmpDir, 'c.txt'), 'c');
const result = await listLocalFiles({ limit: 2, path: tmpDir });
expect(result.files.length).toBe(2);
expect(result.totalCount).toBe(3);
});
it('should handle non-existent directory', async () => {
const result = await listLocalFiles({ path: path.join(tmpDir, 'nope') });
expect(result.files).toEqual([]);
expect(result.totalCount).toBe(0);
});
it('should use default sortBy for unknown sort key', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
const result = await listLocalFiles({
path: tmpDir,
sortBy: 'unknown' as any,
});
expect(result.files.length).toBe(1);
});
it('should mark directories correctly', async () => {
await mkdir(path.join(tmpDir, 'mydir'));
const result = await listLocalFiles({ path: tmpDir });
const dir = result.files.find((f: any) => f.name === 'mydir');
expect(dir.isDirectory).toBe(true);
expect(dir.type).toBe('directory');
});
});
describe('globLocalFiles', () => {
it('should match glob patterns', async () => {
await writeFile(path.join(tmpDir, 'a.ts'), 'a');
await writeFile(path.join(tmpDir, 'b.ts'), 'b');
await writeFile(path.join(tmpDir, 'c.js'), 'c');
const result = await globLocalFiles({ cwd: tmpDir, pattern: '*.ts' });
expect(result.files.length).toBe(2);
expect(result.files).toContain('a.ts');
expect(result.files).toContain('b.ts');
});
it('should ignore node_modules and .git', async () => {
await mkdir(path.join(tmpDir, 'node_modules', 'pkg'), { recursive: true });
await writeFile(path.join(tmpDir, 'node_modules', 'pkg', 'index.ts'), 'x');
await writeFile(path.join(tmpDir, 'src.ts'), 'y');
const result = await globLocalFiles({ cwd: tmpDir, pattern: '**/*.ts' });
expect(result.files).toEqual(['src.ts']);
});
it('should use process.cwd() when cwd not specified', async () => {
const result = await globLocalFiles({ pattern: '*.nonexistent-ext-xyz' });
expect(result.files).toEqual([]);
});
it('should handle invalid pattern gracefully', async () => {
// fast-glob handles most patterns; test with a simple one
const result = await globLocalFiles({ cwd: tmpDir, pattern: '*.txt' });
expect(result.files).toEqual([]);
});
});
describe('editLocalFile edge cases', () => {
it('should count lines added and deleted', async () => {
const filePath = path.join(tmpDir, 'multiline.txt');
await writeFile(filePath, 'line1\nline2\nline3');
const result = await editLocalFile({
file_path: filePath,
new_string: 'newA\nnewB\nnewC\nnewD',
old_string: 'line2',
});
expect(result.success).toBe(true);
expect(result.linesAdded).toBeGreaterThan(0);
expect(result.linesDeleted).toBeGreaterThan(0);
});
});
describe('grepContent', () => {
it('should return matches using ripgrep', async () => {
await writeFile(path.join(tmpDir, 'search.txt'), 'hello world\nfoo bar\nhello again');
const result = await grepContent({ cwd: tmpDir, pattern: 'hello' });
// Result depends on whether rg is installed
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('matches');
});
it('should support file pattern filter', async () => {
await writeFile(path.join(tmpDir, 'test.ts'), 'const x = 1;');
await writeFile(path.join(tmpDir, 'test.js'), 'const y = 2;');
const result = await grepContent({
cwd: tmpDir,
filePattern: '*.ts',
pattern: 'const',
});
expect(result).toHaveProperty('success');
});
it('should handle no matches', async () => {
await writeFile(path.join(tmpDir, 'empty.txt'), 'nothing here');
const result = await grepContent({ cwd: tmpDir, pattern: 'xyz_not_found' });
expect(result.matches).toEqual([]);
});
});
describe('searchLocalFiles', () => {
it('should find files by keyword', async () => {
await writeFile(path.join(tmpDir, 'config.json'), '{}');
await writeFile(path.join(tmpDir, 'config.yaml'), '');
await writeFile(path.join(tmpDir, 'readme.md'), '');
const result = await searchLocalFiles({ directory: tmpDir, keywords: 'config' });
expect(result.length).toBe(2);
expect(result.map((r: any) => r.name)).toContain('config.json');
});
it('should filter by content', async () => {
await writeFile(path.join(tmpDir, 'match.txt'), 'this has the secret');
await writeFile(path.join(tmpDir, 'nomatch.txt'), 'nothing here');
// Search with a broad pattern and content filter
const result = await searchLocalFiles({
contentContains: 'secret',
directory: tmpDir,
keywords: '',
});
// Content filtering should exclude files without 'secret'
expect(result.every((r: any) => r.name !== 'nomatch.txt' || false)).toBe(true);
});
it('should respect limit', async () => {
for (let i = 0; i < 5; i++) {
await writeFile(path.join(tmpDir, `file${i}.log`), `content ${i}`);
}
const result = await searchLocalFiles({
directory: tmpDir,
keywords: 'file',
limit: 2,
});
expect(result.length).toBe(2);
});
it('should use cwd when directory not specified', async () => {
const result = await searchLocalFiles({ keywords: 'nonexistent_xyz_file' });
expect(Array.isArray(result)).toBe(true);
});
it('should handle errors gracefully', async () => {
const result = await searchLocalFiles({
directory: '/nonexistent/path/xyz',
keywords: 'test',
});
expect(result).toEqual([]);
});
});
});

357
apps/cli/src/tools/file.ts Normal file
View File

@@ -0,0 +1,357 @@
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { createPatch } from 'diff';
import fg from 'fast-glob';
import { log } from '../utils/logger';
// ─── readLocalFile ───
interface ReadFileParams {
fullContent?: boolean;
loc?: [number, number];
path: string;
}
export async function readLocalFile({ path: filePath, loc, fullContent }: ReadFileParams) {
const effectiveLoc = fullContent ? undefined : (loc ?? [0, 200]);
log.debug(`Reading file: ${filePath}, loc=${JSON.stringify(effectiveLoc)}`);
try {
const content = await readFile(filePath, 'utf8');
const lines = content.split('\n');
const totalLineCount = lines.length;
const totalCharCount = content.length;
let selectedContent: string;
let lineCount: number;
let actualLoc: [number, number];
if (effectiveLoc === undefined) {
selectedContent = content;
lineCount = totalLineCount;
actualLoc = [0, totalLineCount];
} else {
const [startLine, endLine] = effectiveLoc;
const selectedLines = lines.slice(startLine, endLine);
selectedContent = selectedLines.join('\n');
lineCount = selectedLines.length;
actualLoc = effectiveLoc;
}
const fileStat = await stat(filePath);
return {
charCount: selectedContent.length,
content: selectedContent,
createdTime: fileStat.birthtime,
fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown',
filename: path.basename(filePath),
lineCount,
loc: actualLoc,
modifiedTime: fileStat.mtime,
totalCharCount,
totalLineCount,
};
} catch (error) {
const errorMessage = (error as Error).message;
return {
charCount: 0,
content: `Error accessing or processing file: ${errorMessage}`,
createdTime: new Date(),
fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown',
filename: path.basename(filePath),
lineCount: 0,
loc: [0, 0] as [number, number],
modifiedTime: new Date(),
totalCharCount: 0,
totalLineCount: 0,
};
}
}
// ─── writeLocalFile ───
interface WriteFileParams {
content: string;
path: string;
}
export async function writeLocalFile({ path: filePath, content }: WriteFileParams) {
if (!filePath) return { error: 'Path cannot be empty', success: false };
if (content === undefined) return { error: 'Content cannot be empty', success: false };
try {
const dirname = path.dirname(filePath);
await mkdir(dirname, { recursive: true });
await writeFile(filePath, content, 'utf8');
log.debug(`File written: ${filePath} (${content.length} chars)`);
return { success: true };
} catch (error) {
return { error: `Failed to write file: ${(error as Error).message}`, success: false };
}
}
// ─── editLocalFile ───
interface EditFileParams {
file_path: string;
new_string: string;
old_string: string;
replace_all?: boolean;
}
export async function editLocalFile({
file_path: filePath,
old_string,
new_string,
replace_all = false,
}: EditFileParams) {
try {
const content = await readFile(filePath, 'utf8');
if (!content.includes(old_string)) {
return {
error: 'The specified old_string was not found in the file',
replacements: 0,
success: false,
};
}
let newContent: string;
let replacements: number;
if (replace_all) {
const regex = new RegExp(old_string.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'), 'g');
const matches = content.match(regex);
replacements = matches ? matches.length : 0;
newContent = content.replaceAll(old_string, new_string);
} else {
const index = content.indexOf(old_string);
if (index === -1) {
return { error: 'Old string not found', replacements: 0, success: false };
}
newContent = content.slice(0, index) + new_string + content.slice(index + old_string.length);
replacements = 1;
}
await writeFile(filePath, newContent, 'utf8');
const patch = createPatch(filePath, content, newContent, '', '');
const diffText = `diff --git a${filePath} b${filePath}\n${patch}`;
const patchLines = patch.split('\n');
let linesAdded = 0;
let linesDeleted = 0;
for (const line of patchLines) {
if (line.startsWith('+') && !line.startsWith('+++')) linesAdded++;
else if (line.startsWith('-') && !line.startsWith('---')) linesDeleted++;
}
return { diffText, linesAdded, linesDeleted, replacements, success: true };
} catch (error) {
return { error: (error as Error).message, replacements: 0, success: false };
}
}
// ─── listLocalFiles ───
interface ListFilesParams {
limit?: number;
path: string;
sortBy?: 'createdTime' | 'modifiedTime' | 'name' | 'size';
sortOrder?: 'asc' | 'desc';
}
export async function listLocalFiles({
path: dirPath,
sortBy = 'modifiedTime',
sortOrder = 'desc',
limit = 100,
}: ListFilesParams) {
try {
const entries = await readdir(dirPath);
const results: any[] = [];
for (const entry of entries) {
const fullPath = path.join(dirPath, entry);
try {
const stats = await stat(fullPath);
const isDirectory = stats.isDirectory();
results.push({
createdTime: stats.birthtime,
isDirectory,
lastAccessTime: stats.atime,
modifiedTime: stats.mtime,
name: entry,
path: fullPath,
size: stats.size,
type: isDirectory ? 'directory' : path.extname(entry).toLowerCase().replace('.', ''),
});
} catch {
// Skip files we can't stat
}
}
results.sort((a, b) => {
let comparison: number;
switch (sortBy) {
case 'name': {
comparison = (a.name || '').localeCompare(b.name || '');
break;
}
case 'modifiedTime': {
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
break;
}
case 'createdTime': {
comparison = a.createdTime.getTime() - b.createdTime.getTime();
break;
}
case 'size': {
comparison = a.size - b.size;
break;
}
default: {
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
}
}
return sortOrder === 'desc' ? -comparison : comparison;
});
const totalCount = results.length;
return { files: results.slice(0, limit), totalCount };
} catch (error) {
log.error(`Failed to list directory ${dirPath}:`, error);
return { files: [], totalCount: 0 };
}
}
// ─── globLocalFiles ───
interface GlobFilesParams {
cwd?: string;
pattern: string;
}
export async function globLocalFiles({ pattern, cwd }: GlobFilesParams) {
try {
const files = await fg(pattern, {
cwd: cwd || process.cwd(),
dot: false,
ignore: ['**/node_modules/**', '**/.git/**'],
});
return { files };
} catch (error) {
return { error: (error as Error).message, files: [] };
}
}
// ─── grepContent ───
interface GrepContentParams {
cwd?: string;
filePattern?: string;
pattern: string;
}
export async function grepContent({ pattern, cwd, filePattern }: GrepContentParams) {
const { spawn } = await import('node:child_process');
return new Promise<{ matches: any[]; success: boolean }>((resolve) => {
const args = ['--json', '-n'];
if (filePattern) args.push('--glob', filePattern);
args.push(pattern);
const child = spawn('rg', args, { cwd: cwd || process.cwd() });
let stdout = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', () => {
// stderr consumed but not used
});
child.on('close', (code) => {
if (code !== 0 && code !== 1) {
// Fallback: use simple regex search
log.debug('rg not available, falling back to simple search');
resolve({ matches: [], success: false });
return;
}
try {
const matches = stdout
.split('\n')
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
resolve({ matches, success: true });
} catch {
resolve({ matches: [], success: true });
}
});
child.on('error', () => {
log.debug('rg not available');
resolve({ matches: [], success: false });
});
});
}
// ─── searchLocalFiles ───
interface SearchFilesParams {
contentContains?: string;
directory?: string;
keywords: string;
limit?: number;
}
export async function searchLocalFiles({
keywords,
directory,
contentContains,
limit = 30,
}: SearchFilesParams) {
try {
const cwd = directory || process.cwd();
const files = await fg(`**/*${keywords}*`, {
cwd,
dot: false,
ignore: ['**/node_modules/**', '**/.git/**'],
});
let results = files.map((f) => ({ name: path.basename(f), path: path.join(cwd, f) }));
if (contentContains) {
const filtered: typeof results = [];
for (const file of results) {
try {
const content = await readFile(file.path, 'utf8');
if (content.includes(contentContains)) {
filtered.push(file);
}
} catch {
// Skip unreadable files
}
}
results = filtered;
}
return results.slice(0, limit);
} catch (error) {
log.error('File search failed:', error);
return [];
}
}

View File

@@ -0,0 +1,176 @@
import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { executeToolCall } from './index';
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
describe('executeToolCall', () => {
const tmpDir = path.join(os.tmpdir(), 'cli-tool-dispatch-test-' + process.pid);
beforeEach(async () => {
await mkdir(tmpDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { force: true, recursive: true });
});
it('should dispatch readLocalFile', async () => {
const filePath = path.join(tmpDir, 'test.txt');
await writeFile(filePath, 'hello world');
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.content).toContain('hello world');
});
it('should dispatch writeLocalFile', async () => {
const filePath = path.join(tmpDir, 'new.txt');
const result = await executeToolCall(
'writeLocalFile',
JSON.stringify({ content: 'written', path: filePath }),
);
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('written');
});
it('should dispatch runCommand', async () => {
const result = await executeToolCall(
'runCommand',
JSON.stringify({ command: 'echo dispatched' }),
);
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.stdout).toContain('dispatched');
});
it('should dispatch listLocalFiles', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
const result = await executeToolCall('listLocalFiles', JSON.stringify({ path: tmpDir }));
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.totalCount).toBeGreaterThan(0);
});
it('should dispatch globLocalFiles', async () => {
await writeFile(path.join(tmpDir, 'test.ts'), 'code');
const result = await executeToolCall(
'globLocalFiles',
JSON.stringify({ cwd: tmpDir, pattern: '*.ts' }),
);
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.files).toContain('test.ts');
});
it('should dispatch editLocalFile', async () => {
const filePath = path.join(tmpDir, 'edit.txt');
await writeFile(filePath, 'old content');
const result = await executeToolCall(
'editLocalFile',
JSON.stringify({
file_path: filePath,
new_string: 'new content',
old_string: 'old content',
}),
);
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('new content');
});
it('should return error for unknown API', async () => {
const result = await executeToolCall('unknownApi', '{}');
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown tool API');
});
it('should handle tool that returns a string result', async () => {
// runCommand returns an object, but we test the string branch by mocking
// Actually, none of the tools return plain strings, so the JSON.stringify branch
// is always taken. The string check is for future-proofing.
// Let's verify the JSON output path
const filePath = path.join(tmpDir, 'str.txt');
await writeFile(filePath, 'content');
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
// Result should be valid JSON
expect(() => JSON.parse(result.content)).not.toThrow();
});
it('should return error for invalid JSON arguments', async () => {
const result = await executeToolCall('readLocalFile', 'not-json');
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
it('should dispatch grepContent', async () => {
await writeFile(path.join(tmpDir, 'grep.txt'), 'findme here');
const result = await executeToolCall(
'grepContent',
JSON.stringify({ cwd: tmpDir, pattern: 'findme' }),
);
expect(result.success).toBe(true);
});
it('should dispatch searchLocalFiles', async () => {
await writeFile(path.join(tmpDir, 'search_target.txt'), 'found');
const result = await executeToolCall(
'searchLocalFiles',
JSON.stringify({ directory: tmpDir, keywords: 'search_target' }),
);
expect(result.success).toBe(true);
});
it('should dispatch getCommandOutput', async () => {
const result = await executeToolCall(
'getCommandOutput',
JSON.stringify({ shell_id: 'nonexistent' }),
);
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.success).toBe(false);
});
it('should dispatch killCommand', async () => {
const result = await executeToolCall(
'killCommand',
JSON.stringify({ shell_id: 'nonexistent' }),
);
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.success).toBe(false);
});
});

View File

@@ -0,0 +1,51 @@
import { log } from '../utils/logger';
import {
editLocalFile,
globLocalFiles,
grepContent,
listLocalFiles,
readLocalFile,
searchLocalFiles,
writeLocalFile,
} from './file';
import { getCommandOutput, killCommand, runCommand } from './shell';
const methodMap: Record<string, (args: any) => Promise<unknown>> = {
editLocalFile,
getCommandOutput,
globLocalFiles,
grepContent,
killCommand,
listLocalFiles,
readLocalFile,
runCommand,
searchLocalFiles,
writeLocalFile,
};
export async function executeToolCall(
apiName: string,
argsStr: string,
): Promise<{
content: string;
error?: string;
success: boolean;
}> {
const handler = methodMap[apiName];
if (!handler) {
return { content: '', error: `Unknown tool API: ${apiName}`, success: false };
}
try {
const args = JSON.parse(argsStr);
const result = await handler(args);
const content = typeof result === 'string' ? result : JSON.stringify(result);
return { content, success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
log.error(`Tool call failed: ${apiName} - ${errorMsg}`);
return { content: '', error: errorMsg, success: false };
}
}

View File

@@ -0,0 +1,237 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanupAllProcesses, getCommandOutput, killCommand, runCommand } from './shell';
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
describe('shell tools', () => {
afterEach(() => {
cleanupAllProcesses();
});
describe('runCommand', () => {
it('should execute a simple command', async () => {
const result = await runCommand({ command: 'echo hello' });
expect(result.success).toBe(true);
expect(result.stdout).toContain('hello');
expect(result.exit_code).toBe(0);
});
it('should capture stderr', async () => {
const result = await runCommand({ command: 'echo error >&2' });
expect(result.stderr).toContain('error');
});
it('should handle command failure', async () => {
const result = await runCommand({ command: 'exit 1' });
expect(result.success).toBe(false);
expect(result.exit_code).toBe(1);
});
it('should handle command not found', async () => {
const result = await runCommand({ command: 'nonexistent_command_xyz_123' });
expect(result.success).toBe(false);
});
it('should timeout long-running commands', async () => {
const result = await runCommand({ command: 'sleep 10', timeout: 500 });
expect(result.success).toBe(false);
expect(result.error).toContain('timed out');
}, 10000);
it('should clamp timeout to minimum 1000ms', async () => {
const result = await runCommand({ command: 'echo fast', timeout: 100 });
expect(result.success).toBe(true);
});
it('should run command in background', async () => {
const result = await runCommand({
command: 'echo background',
run_in_background: true,
});
expect(result.success).toBe(true);
expect(result.shell_id).toBeDefined();
});
it('should strip ANSI codes from output', async () => {
const result = await runCommand({
command: 'printf "\\033[31mred\\033[0m"',
});
expect(result.output).not.toContain('\u001B');
});
it('should truncate very long output', async () => {
// Generate output longer than 80KB
const result = await runCommand({
command: `python3 -c "print('x' * 100000)" 2>/dev/null || printf '%0.sx' $(seq 1 100000)`,
});
// Output should be truncated
expect(result.output.length).toBeLessThanOrEqual(85000); // 80000 + truncation message
}, 15000);
it('should use description in log prefix', async () => {
const result = await runCommand({
command: 'echo test',
description: 'test command',
});
expect(result.success).toBe(true);
});
});
describe('getCommandOutput', () => {
it('should get output from background process', async () => {
const bgResult = await runCommand({
command: 'echo hello && sleep 0.1',
run_in_background: true,
});
// Wait for output to be captured
await new Promise((r) => setTimeout(r, 200));
const output = await getCommandOutput({ shell_id: bgResult.shell_id });
expect(output.success).toBe(true);
expect(output.stdout).toContain('hello');
});
it('should return error for unknown shell_id', async () => {
const result = await getCommandOutput({ shell_id: 'unknown-id' });
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
it('should track running state', async () => {
const bgResult = await runCommand({
command: 'sleep 5',
run_in_background: true,
});
const output = await getCommandOutput({ shell_id: bgResult.shell_id });
expect(output.running).toBe(true);
});
it('should support filter parameter', async () => {
const bgResult = await runCommand({
command: 'echo "line1\nline2\nline3"',
run_in_background: true,
});
await new Promise((r) => setTimeout(r, 200));
const output = await getCommandOutput({
filter: 'line2',
shell_id: bgResult.shell_id,
});
expect(output.success).toBe(true);
});
it('should handle invalid filter regex', async () => {
const bgResult = await runCommand({
command: 'echo test',
run_in_background: true,
});
await new Promise((r) => setTimeout(r, 200));
const output = await getCommandOutput({
filter: '[invalid',
shell_id: bgResult.shell_id,
});
expect(output.success).toBe(true);
});
it('should return new output only on subsequent calls', async () => {
const bgResult = await runCommand({
command: 'echo first && sleep 0.2 && echo second',
run_in_background: true,
});
await new Promise((r) => setTimeout(r, 100));
const first = await getCommandOutput({ shell_id: bgResult.shell_id });
await new Promise((r) => setTimeout(r, 300));
await getCommandOutput({ shell_id: bgResult.shell_id });
// First read should have "first"
expect(first.stdout).toContain('first');
});
});
describe('killCommand', () => {
it('should kill a background process', async () => {
const bgResult = await runCommand({
command: 'sleep 60',
run_in_background: true,
});
const result = await killCommand({ shell_id: bgResult.shell_id });
expect(result.success).toBe(true);
});
it('should return error for unknown shell_id', async () => {
const result = await killCommand({ shell_id: 'unknown-id' });
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
});
describe('killCommand error handling', () => {
it('should handle kill error on already-dead process', async () => {
const bgResult = await runCommand({
command: 'echo done',
run_in_background: true,
});
// Wait for process to finish
await new Promise((r) => setTimeout(r, 200));
// Process is already done, killing should still succeed or return error
const result = await killCommand({ shell_id: bgResult.shell_id });
// It may succeed (process already exited) or fail, but shouldn't throw
expect(result).toHaveProperty('success');
});
});
describe('runCommand error handling', () => {
it('should handle spawn error for non-existent shell', async () => {
// Test with a command that causes spawn error
const result = await runCommand({ command: 'echo test' });
// Normal command should work
expect(result).toHaveProperty('success');
});
});
describe('cleanupAllProcesses', () => {
it('should kill all background processes', async () => {
await runCommand({ command: 'sleep 60', run_in_background: true });
await runCommand({ command: 'sleep 60', run_in_background: true });
cleanupAllProcesses();
// No processes should remain - subsequent getCommandOutput should fail
});
});
});

233
apps/cli/src/tools/shell.ts Normal file
View File

@@ -0,0 +1,233 @@
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { log } from '../utils/logger';
// Maximum output length to prevent context explosion
const MAX_OUTPUT_LENGTH = 80_000;
const ANSI_REGEX =
// eslint-disable-next-line no-control-regex
/\u001B(?:[\u0040-\u005A\u005C-\u005F]|\[[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E])/g;
const stripAnsi = (str: string): string => str.replaceAll(ANSI_REGEX, '');
const truncateOutput = (str: string, maxLength: number = MAX_OUTPUT_LENGTH): string => {
const cleaned = stripAnsi(str);
if (cleaned.length <= maxLength) return cleaned;
return (
cleaned.slice(0, maxLength) +
'\n... [truncated, ' +
(cleaned.length - maxLength) +
' more characters]'
);
};
interface ShellProcess {
lastReadStderr: number;
lastReadStdout: number;
process: ChildProcess;
stderr: string[];
stdout: string[];
}
const shellProcesses = new Map<string, ShellProcess>();
export function cleanupAllProcesses() {
for (const [id, sp] of shellProcesses) {
try {
sp.process.kill();
} catch {
// Ignore
}
shellProcesses.delete(id);
}
}
// ─── runCommand ───
interface RunCommandParams {
command: string;
description?: string;
run_in_background?: boolean;
timeout?: number;
}
export async function runCommand({
command,
description,
run_in_background,
timeout = 120_000,
}: RunCommandParams) {
const logPrefix = `[runCommand: ${description || command.slice(0, 50)}]`;
log.debug(`${logPrefix} Starting`, { background: run_in_background, timeout });
const effectiveTimeout = Math.min(Math.max(timeout, 1000), 600_000);
const shellConfig =
process.platform === 'win32'
? { args: ['/c', command], cmd: 'cmd.exe' }
: { args: ['-c', command], cmd: '/bin/sh' };
try {
if (run_in_background) {
const shellId = randomUUID();
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
env: process.env,
shell: false,
});
const shellProcess: ShellProcess = {
lastReadStderr: 0,
lastReadStdout: 0,
process: childProcess,
stderr: [],
stdout: [],
};
childProcess.stdout?.on('data', (data) => {
shellProcess.stdout.push(data.toString());
});
childProcess.stderr?.on('data', (data) => {
shellProcess.stderr.push(data.toString());
});
childProcess.on('exit', (code) => {
log.debug(`${logPrefix} Background process exited`, { code, shellId });
});
shellProcesses.set(shellId, shellProcess);
log.debug(`${logPrefix} Started background`, { shellId });
return { shell_id: shellId, success: true };
} else {
return new Promise<any>((resolve) => {
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
env: process.env,
shell: false,
});
let stdout = '';
let stderr = '';
let killed = false;
const timeoutHandle = setTimeout(() => {
killed = true;
childProcess.kill();
resolve({
error: `Command timed out after ${effectiveTimeout}ms`,
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success: false,
});
}, effectiveTimeout);
childProcess.stdout?.on('data', (data) => {
stdout += data.toString();
});
childProcess.stderr?.on('data', (data) => {
stderr += data.toString();
});
childProcess.on('exit', (code) => {
if (!killed) {
clearTimeout(timeoutHandle);
const success = code === 0;
resolve({
exit_code: code || 0,
output: truncateOutput(stdout + stderr),
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success,
});
}
});
childProcess.on('error', (error) => {
clearTimeout(timeoutHandle);
resolve({
error: error.message,
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success: false,
});
});
});
}
} catch (error) {
return { error: (error as Error).message, success: false };
}
}
// ─── getCommandOutput ───
interface GetCommandOutputParams {
filter?: string;
shell_id: string;
}
export async function getCommandOutput({ shell_id, filter }: GetCommandOutputParams) {
const shellProcess = shellProcesses.get(shell_id);
if (!shellProcess) {
return {
error: `Shell ID ${shell_id} not found`,
output: '',
running: false,
stderr: '',
stdout: '',
success: false,
};
}
const { lastReadStderr, lastReadStdout, process: childProcess, stderr, stdout } = shellProcess;
const newStdout = stdout.slice(lastReadStdout).join('');
const newStderr = stderr.slice(lastReadStderr).join('');
let output = newStdout + newStderr;
if (filter) {
try {
const regex = new RegExp(filter, 'gm');
const lines = output.split('\n');
output = lines.filter((line) => regex.test(line)).join('\n');
} catch {
// Invalid filter regex, use unfiltered output
}
}
shellProcess.lastReadStdout = stdout.length;
shellProcess.lastReadStderr = stderr.length;
const running = childProcess.exitCode === null;
return {
output: truncateOutput(output),
running,
stderr: truncateOutput(newStderr),
stdout: truncateOutput(newStdout),
success: true,
};
}
// ─── killCommand ───
interface KillCommandParams {
shell_id: string;
}
export async function killCommand({ shell_id }: KillCommandParams) {
const shellProcess = shellProcesses.get(shell_id);
if (!shellProcess) {
return { error: `Shell ID ${shell_id} not found`, success: false };
}
try {
shellProcess.process.kill();
shellProcesses.delete(shell_id);
return { success: true };
} catch (error) {
return { error: (error as Error).message, success: false };
}
}

View File

@@ -0,0 +1,155 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { log, setVerbose } from './logger';
describe('logger', () => {
const consoleSpy = {
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
};
const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
afterEach(() => {
setVerbose(false);
vi.clearAllMocks();
});
describe('info', () => {
it('should log info messages', () => {
log.info('test message');
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining('[INFO]'),
// No extra args
);
});
it('should pass extra args', () => {
log.info('test %s', 'arg1');
expect(consoleSpy.log).toHaveBeenCalled();
});
});
describe('error', () => {
it('should log error messages', () => {
log.error('error message');
expect(consoleSpy.error).toHaveBeenCalledWith(expect.stringContaining('[ERROR]'));
});
});
describe('warn', () => {
it('should log warning messages', () => {
log.warn('warning message');
expect(consoleSpy.warn).toHaveBeenCalledWith(expect.stringContaining('[WARN]'));
});
});
describe('debug', () => {
it('should not log when verbose is false', () => {
log.debug('debug message');
expect(consoleSpy.log).not.toHaveBeenCalled();
});
it('should log when verbose is true', () => {
setVerbose(true);
log.debug('debug message');
expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('[DEBUG]'));
});
});
describe('heartbeat', () => {
it('should not write when verbose is false', () => {
log.heartbeat();
expect(stdoutWriteSpy).not.toHaveBeenCalled();
});
it('should write dot when verbose is true', () => {
setVerbose(true);
log.heartbeat();
expect(stdoutWriteSpy).toHaveBeenCalled();
});
});
describe('status', () => {
it('should log connected status', () => {
log.status('connected');
expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('[STATUS]'));
});
it('should log disconnected status', () => {
log.status('disconnected');
expect(consoleSpy.log).toHaveBeenCalled();
});
it('should log other status', () => {
log.status('connecting');
expect(consoleSpy.log).toHaveBeenCalled();
});
});
describe('toolCall', () => {
it('should log tool call', () => {
log.toolCall('readFile', 'req-1');
expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('[TOOL]'));
});
it('should log args when verbose', () => {
setVerbose(true);
log.toolCall('readFile', 'req-1', '{"path": "/test"}');
// Should have been called twice (tool call + args)
expect(consoleSpy.log).toHaveBeenCalledTimes(2);
});
it('should not log args when not verbose', () => {
log.toolCall('readFile', 'req-1', '{"path": "/test"}');
expect(consoleSpy.log).toHaveBeenCalledTimes(1);
});
});
describe('toolResult', () => {
it('should log success result', () => {
log.toolResult('req-1', true);
expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('[RESULT]'));
});
it('should log failure result', () => {
log.toolResult('req-1', false);
expect(consoleSpy.log).toHaveBeenCalled();
});
it('should log content preview when verbose', () => {
setVerbose(true);
log.toolResult('req-1', true, 'some content');
expect(consoleSpy.log).toHaveBeenCalledTimes(2);
});
it('should truncate long content in preview', () => {
setVerbose(true);
log.toolResult('req-1', true, 'x'.repeat(300));
expect(consoleSpy.log).toHaveBeenCalledTimes(2);
// The second call should have truncated content
const lastCall = consoleSpy.log.mock.calls[1][0];
expect(lastCall).toContain('...');
});
it('should not log content when not verbose', () => {
log.toolResult('req-1', true, 'some content');
expect(consoleSpy.log).toHaveBeenCalledTimes(1);
});
});
describe('setVerbose', () => {
it('should enable verbose mode', () => {
setVerbose(true);
log.debug('should appear');
expect(consoleSpy.log).toHaveBeenCalled();
});
it('should disable verbose mode', () => {
setVerbose(true);
setVerbose(false);
log.debug('should not appear');
expect(consoleSpy.log).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,65 @@
/* eslint-disable no-console */
import pc from 'picocolors';
let verbose = false;
export const setVerbose = (v: boolean) => {
verbose = v;
};
const timestamp = (): string => {
const now = new Date();
return pc.dim(
`${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`,
);
};
export const log = {
debug: (msg: string, ...args: unknown[]) => {
if (verbose) {
console.log(`${timestamp()} ${pc.dim('[DEBUG]')} ${msg}`, ...args);
}
},
error: (msg: string, ...args: unknown[]) => {
console.error(`${timestamp()} ${pc.red('[ERROR]')} ${pc.red(msg)}`, ...args);
},
heartbeat: () => {
if (verbose) {
process.stdout.write(pc.dim('.'));
}
},
info: (msg: string, ...args: unknown[]) => {
console.log(`${timestamp()} ${pc.blue('[INFO]')} ${msg}`, ...args);
},
status: (status: string) => {
const color =
status === 'connected' ? pc.green : status === 'disconnected' ? pc.red : pc.yellow;
console.log(`${timestamp()} ${pc.bold('[STATUS]')} ${color(status)}`);
},
toolCall: (apiName: string, requestId: string, args?: string) => {
console.log(
`${timestamp()} ${pc.magenta('[TOOL]')} ${pc.bold(apiName)} ${pc.dim(`(${requestId})`)}`,
);
if (args && verbose) {
console.log(` ${pc.dim(args)}`);
}
},
toolResult: (requestId: string, success: boolean, content?: string) => {
const icon = success ? pc.green('OK') : pc.red('FAIL');
console.log(`${timestamp()} ${pc.magenta('[RESULT]')} ${icon} ${pc.dim(`(${requestId})`)}`);
if (content && verbose) {
const preview = content.length > 200 ? content.slice(0, 200) + '...' : content;
console.log(` ${pc.dim(preview)}`);
}
},
warn: (msg: string, ...args: unknown[]) => {
console.warn(`${timestamp()} ${pc.yellow('[WARN]')} ${pc.yellow(msg)}`, ...args);
},
};

20
apps/cli/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext"],
"types": ["node"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"paths": {
"@lobechat/device-gateway-client": ["../../packages/device-gateway-client/src"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,23 @@
import path from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
alias: [
{
find: '@lobechat/device-gateway-client',
replacement: path.resolve(__dirname, '../../packages/device-gateway-client/src/index.ts'),
},
],
},
test: {
coverage: {
all: false,
reporter: ['text', 'json', 'lcov', 'text-summary'],
},
environment: 'node',
// Suppress unhandled rejection warnings from Commander async actions with mocked process.exit
onConsoleLog: () => true,
},
});

View File

@@ -5,14 +5,19 @@
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit"
},
"dependencies": {
"hono": "^4.12.5",
"jose": "^6.1.3"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250214.0",
"@cloudflare/vitest-pool-workers": "^0.12.19",
"@cloudflare/workers-types": "^4.20260301.1",
"typescript": "^5.9.3",
"wrangler": "^4.14.4"
"vitest": "~3.2.4",
"wrangler": "^4.70.0"
}
}

View File

@@ -1,7 +1,13 @@
import { DurableObject } from 'cloudflare:workers';
import { Hono } from 'hono';
import { verifyDesktopToken } from './auth';
import type { DeviceAttachment, Env } from './types';
const AUTH_TIMEOUT = 10_000; // 10s to authenticate after connect
const HEARTBEAT_TIMEOUT = 90_000; // 90s without heartbeat → close
const HEARTBEAT_CHECK_INTERVAL = 90_000; // check every 90s
export class DeviceGatewayDO extends DurableObject<Env> {
private pendingRequests = new Map<
string,
@@ -11,58 +17,91 @@ export class DeviceGatewayDO extends DurableObject<Env> {
}
>();
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// ─── WebSocket upgrade (from Desktop) ───
if (request.headers.get('Upgrade') === 'websocket') {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server);
const deviceId = url.searchParams.get('deviceId') || 'unknown';
const hostname = url.searchParams.get('hostname') || '';
const platform = url.searchParams.get('platform') || '';
server.serializeAttachment({
connectedAt: Date.now(),
deviceId,
hostname,
platform,
} satisfies DeviceAttachment);
return new Response(null, { status: 101, webSocket: client });
}
// ─── HTTP API (from Vercel Agent) ───
if (url.pathname === '/api/device/status') {
const sockets = this.ctx.getWebSockets();
private router = new Hono()
.all('/api/device/status', async () => {
const sockets = this.getAuthenticatedSockets();
return Response.json({
deviceCount: sockets.length,
online: sockets.length > 0,
});
}
if (url.pathname === '/api/device/tool-call') {
return this.handleToolCall(request);
}
if (url.pathname === '/api/device/devices') {
const sockets = this.ctx.getWebSockets();
})
.post('/api/device/tool-call', async (c) => {
return this.handleToolCall(c.req.raw);
})
.post('/api/device/system-info', async (c) => {
return this.handleSystemInfo(c.req.raw);
})
.all('/api/device/devices', async () => {
const sockets = this.getAuthenticatedSockets();
const devices = sockets.map((ws) => ws.deserializeAttachment() as DeviceAttachment);
return Response.json({ devices });
});
async fetch(request: Request): Promise<Response> {
// ─── WebSocket upgrade (from Desktop) ───
if (request.headers.get('Upgrade') === 'websocket') {
return this.handleWebSocketUpgrade(request);
}
return new Response('Not Found', { status: 404 });
// ─── HTTP API routes ───
return this.router.fetch(request);
}
// ─── Hibernation Handlers ───
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
const data = JSON.parse(message as string);
const att = ws.deserializeAttachment() as DeviceAttachment;
if (data.type === 'tool_call_response') {
// ─── Auth message handling ───
if (data.type === 'auth') {
if (att.authenticated) return; // Already authenticated, ignore
try {
const token = data.token as string;
if (!token) throw new Error('Missing token');
let verifiedUserId: string;
if (token === this.env.SERVICE_TOKEN) {
// Service token auth (for CLI debugging)
const storedUserId = await this.ctx.storage.get<string>('_userId');
if (!storedUserId) throw new Error('Missing userId');
verifiedUserId = storedUserId;
} else {
// JWT auth (normal desktop flow)
const result = await verifyDesktopToken(this.env, token);
verifiedUserId = result.userId;
}
// Verify userId matches the DO routing
const storedUserId = await this.ctx.storage.get<string>('_userId');
if (storedUserId && verifiedUserId !== storedUserId) {
throw new Error('userId mismatch');
}
// Mark as authenticated
att.authenticated = true;
att.authDeadline = undefined;
ws.serializeAttachment(att);
ws.send(JSON.stringify({ type: 'auth_success' }));
// Schedule heartbeat check for authenticated connections
await this.scheduleHeartbeatCheck();
} catch (err) {
const reason = err instanceof Error ? err.message : 'Authentication failed';
ws.send(JSON.stringify({ reason, type: 'auth_failed' }));
ws.close(1008, reason);
}
return;
}
// ─── Reject unauthenticated messages ───
if (!att.authenticated) return;
// ─── Business messages (authenticated only) ───
if (data.type === 'tool_call_response' || data.type === 'system_info_response') {
const pending = this.pendingRequests.get(data.requestId);
if (pending) {
clearTimeout(pending.timer);
@@ -72,6 +111,8 @@ export class DeviceGatewayDO extends DurableObject<Env> {
}
if (data.type === 'heartbeat') {
att.lastHeartbeat = Date.now();
ws.serializeAttachment(att);
ws.send(JSON.stringify({ type: 'heartbeat_ack' }));
}
}
@@ -84,10 +125,162 @@ export class DeviceGatewayDO extends DurableObject<Env> {
ws.close(1011, 'Internal error');
}
// ─── Heartbeat Timeout ───
async alarm() {
const now = Date.now();
const closedSockets = new Set<WebSocket>();
for (const ws of this.ctx.getWebSockets()) {
const att = ws.deserializeAttachment() as DeviceAttachment;
// Auth timeout: close unauthenticated connections past deadline
if (!att.authenticated && att.authDeadline && now > att.authDeadline) {
ws.send(JSON.stringify({ reason: 'Authentication timeout', type: 'auth_failed' }));
ws.close(1008, 'Authentication timeout');
closedSockets.add(ws);
continue;
}
// Heartbeat timeout: only for authenticated connections
if (att.authenticated && now - att.lastHeartbeat > HEARTBEAT_TIMEOUT) {
ws.close(1000, 'Heartbeat timeout');
closedSockets.add(ws);
}
}
// Keep alarm running while there are active connections
const remaining = this.ctx.getWebSockets().filter((ws) => !closedSockets.has(ws));
if (remaining.length > 0) {
await this.scheduleHeartbeatCheck();
}
}
// ─── WebSocket Upgrade ───
private async handleWebSocketUpgrade(request: Request): Promise<Response> {
const url = new URL(request.url);
const userId = request.headers.get('X-User-Id');
const deviceId = url.searchParams.get('deviceId') || 'unknown';
const hostname = url.searchParams.get('hostname') || '';
const platform = url.searchParams.get('platform') || '';
// Close stale connection from the same device
for (const ws of this.ctx.getWebSockets()) {
const att = ws.deserializeAttachment() as DeviceAttachment;
if (att.deviceId === deviceId) {
ws.close(1000, 'Replaced by new connection');
}
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server);
const now = Date.now();
server.serializeAttachment({
authDeadline: now + AUTH_TIMEOUT,
authenticated: false,
connectedAt: now,
deviceId,
hostname,
lastHeartbeat: now,
platform,
} satisfies DeviceAttachment);
if (userId) {
await this.ctx.storage.put('_userId', userId);
}
// Schedule auth timeout check (10s)
await this.scheduleAuthTimeout();
return new Response(null, { status: 101, webSocket: client });
}
private async scheduleAuthTimeout() {
const currentAlarm = await this.ctx.storage.getAlarm();
if (!currentAlarm) {
await this.ctx.storage.setAlarm(Date.now() + AUTH_TIMEOUT);
}
}
private async scheduleHeartbeatCheck() {
const currentAlarm = await this.ctx.storage.getAlarm();
if (!currentAlarm) {
await this.ctx.storage.setAlarm(Date.now() + HEARTBEAT_CHECK_INTERVAL);
}
}
// ─── Helpers ───
private getAuthenticatedSockets(): WebSocket[] {
return this.ctx.getWebSockets().filter((ws) => {
const att = ws.deserializeAttachment() as DeviceAttachment;
return att.authenticated;
});
}
// ─── System Info RPC ───
private async handleSystemInfo(request: Request): Promise<Response> {
const sockets = this.getAuthenticatedSockets();
if (sockets.length === 0) {
return Response.json({ error: 'DEVICE_OFFLINE', success: false }, { status: 503 });
}
const { deviceId, timeout = 10_000 } = (await request.json()) as {
deviceId?: string;
timeout?: number;
};
const requestId = crypto.randomUUID();
const targetWs = deviceId
? sockets.find((ws) => {
const att = ws.deserializeAttachment() as DeviceAttachment;
return att.deviceId === deviceId;
})
: sockets[0];
if (!targetWs) {
return Response.json({ error: 'DEVICE_NOT_FOUND', success: false }, { status: 503 });
}
try {
const result = await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(requestId);
reject(new Error('TIMEOUT'));
}, timeout);
this.pendingRequests.set(requestId, { resolve, timer });
targetWs.send(
JSON.stringify({
requestId,
type: 'system_info_request',
}),
);
});
return Response.json({ success: true, ...(result as object) });
} catch (err) {
return Response.json(
{
error: (err as Error).message,
success: false,
},
{ status: 504 },
);
}
}
// ─── Tool Call RPC ───
private async handleToolCall(request: Request): Promise<Response> {
const sockets = this.ctx.getWebSockets();
const sockets = this.getAuthenticatedSockets();
if (sockets.length === 0) {
return Response.json(
{ content: '桌面设备不在线', error: 'DEVICE_OFFLINE', success: false },

View File

@@ -1,52 +1,47 @@
import { verifyDesktopToken } from './auth';
import { Hono } from 'hono';
import { DeviceGatewayDO } from './DeviceGatewayDO';
import type { Env } from './types';
export { DeviceGatewayDO };
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const app = new Hono<{ Bindings: Env }>();
// ─── Health check ───
if (url.pathname === '/health') {
return new Response('OK', { status: 200 });
// ─── Health check ───
app.get('/health', (c) => c.text('OK'));
// ─── Auth middleware for service APIs ───
const serviceAuth = (): ((c: any, next: () => Promise<void>) => Promise<Response | void>) => {
return async (c, next) => {
const authHeader = c.req.header('Authorization');
if (authHeader !== `Bearer ${c.env.SERVICE_TOKEN}`) {
return c.text('Unauthorized', 401);
}
// ─── Desktop WebSocket connection ───
if (url.pathname === '/ws') {
const token = url.searchParams.get('token');
if (!token) return new Response('Missing token', { status: 401 });
try {
const { userId } = await verifyDesktopToken(env, token);
const id = env.DEVICE_GATEWAY.idFromName(`user:${userId}`);
const stub = env.DEVICE_GATEWAY.get(id);
// Forward WebSocket upgrade to DO
const headers = new Headers(request.headers);
headers.set('X-User-Id', userId);
return stub.fetch(new Request(request, { headers }));
} catch {
return new Response('Invalid token', { status: 401 });
}
}
// ─── Vercel Agent HTTP API ───
if (url.pathname.startsWith('/api/device/')) {
const authHeader = request.headers.get('Authorization');
if (authHeader !== `Bearer ${env.SERVICE_TOKEN}`) {
return new Response('Unauthorized', { status: 401 });
}
const body = (await request.clone().json()) as { userId: string };
if (!body.userId) return new Response('Missing userId', { status: 400 });
const id = env.DEVICE_GATEWAY.idFromName(`user:${body.userId}`);
const stub = env.DEVICE_GATEWAY.get(id);
return stub.fetch(request);
}
return new Response('Not Found', { status: 404 });
},
await next();
};
};
// ─── Desktop WebSocket connection ───
app.get('/ws', async (c) => {
const userId = c.req.query('userId');
if (!userId) return c.text('Missing userId', 400);
const id = c.env.DEVICE_GATEWAY.idFromName(`user:${userId}`);
const stub = c.env.DEVICE_GATEWAY.get(id);
const headers = new Headers(c.req.raw.headers);
headers.set('X-User-Id', userId);
return stub.fetch(new Request(c.req.raw, { headers }));
});
// ─── Vercel Agent HTTP API ───
app.all('/api/device/*', serviceAuth(), async (c) => {
const body = (await c.req.raw.clone().json()) as { userId: string };
if (!body.userId) return c.text('Missing userId', 400);
const id = c.env.DEVICE_GATEWAY.idFromName(`user:${body.userId}`);
const stub = c.env.DEVICE_GATEWAY.get(id);
return stub.fetch(c.req.raw);
});
export default app;

View File

@@ -7,15 +7,23 @@ export interface Env {
// ─── Device Info ───
export interface DeviceAttachment {
authDeadline?: number;
authenticated: boolean;
connectedAt: number;
deviceId: string;
hostname: string;
lastHeartbeat: number;
platform: string;
}
// ─── WebSocket Protocol Messages ───
// Desktop → CF
export interface AuthMessage {
token: string;
type: 'auth';
}
export interface HeartbeatMessage {
type: 'heartbeat';
}
@@ -30,7 +38,35 @@ export interface ToolCallResponseMessage {
type: 'tool_call_response';
}
export interface SystemInfoResponseMessage {
requestId: string;
result: DeviceSystemInfo;
type: 'system_info_response';
}
export interface DeviceSystemInfo {
arch: string;
desktopPath: string;
documentsPath: string;
downloadsPath: string;
homePath: string;
musicPath: string;
picturesPath: string;
userDataPath: string;
videosPath: string;
workingDirectory: string;
}
// CF → Desktop
export interface AuthSuccessMessage {
type: 'auth_success';
}
export interface AuthFailedMessage {
reason: string;
type: 'auth_failed';
}
export interface HeartbeatAckMessage {
type: 'heartbeat_ack';
}
@@ -49,5 +85,20 @@ export interface ToolCallRequestMessage {
type: 'tool_call_request';
}
export type ClientMessage = HeartbeatMessage | ToolCallResponseMessage;
export type ServerMessage = AuthExpiredMessage | HeartbeatAckMessage | ToolCallRequestMessage;
export interface SystemInfoRequestMessage {
requestId: string;
type: 'system_info_request';
}
export type ClientMessage =
| AuthMessage
| HeartbeatMessage
| SystemInfoResponseMessage
| ToolCallResponseMessage;
export type ServerMessage =
| AuthExpiredMessage
| AuthFailedMessage
| AuthSuccessMessage
| HeartbeatAckMessage
| SystemInfoRequestMessage
| ToolCallRequestMessage;