mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ 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:
25
apps/cli/package.json
Normal file
25
apps/cli/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
132
apps/cli/src/auth/credentials.test.ts
Normal file
132
apps/cli/src/auth/credentials.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
77
apps/cli/src/auth/credentials.ts
Normal file
77
apps/cli/src/auth/credentials.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
229
apps/cli/src/auth/refresh.test.ts
Normal file
229
apps/cli/src/auth/refresh.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
67
apps/cli/src/auth/refresh.ts
Normal file
67
apps/cli/src/auth/refresh.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
117
apps/cli/src/auth/resolveToken.test.ts
Normal file
117
apps/cli/src/auth/resolveToken.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
65
apps/cli/src/auth/resolveToken.ts
Normal file
65
apps/cli/src/auth/resolveToken.ts
Normal 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);
|
||||
}
|
||||
254
apps/cli/src/commands/connect.test.ts
Normal file
254
apps/cli/src/commands/connect.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
153
apps/cli/src/commands/connect.ts
Normal file
153
apps/cli/src/commands/connect.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
250
apps/cli/src/commands/login.test.ts
Normal file
250
apps/cli/src/commands/login.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
178
apps/cli/src/commands/login.ts
Normal file
178
apps/cli/src/commands/login.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
47
apps/cli/src/commands/logout.test.ts
Normal file
47
apps/cli/src/commands/logout.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
18
apps/cli/src/commands/logout.ts
Normal file
18
apps/cli/src/commands/logout.ts
Normal 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.');
|
||||
}
|
||||
});
|
||||
}
|
||||
164
apps/cli/src/commands/status.test.ts
Normal file
164
apps/cli/src/commands/status.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
78
apps/cli/src/commands/status.ts
Normal file
78
apps/cli/src/commands/status.ts
Normal 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
22
apps/cli/src/index.ts
Normal 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();
|
||||
458
apps/cli/src/tools/file.test.ts
Normal file
458
apps/cli/src/tools/file.test.ts
Normal 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
357
apps/cli/src/tools/file.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
176
apps/cli/src/tools/index.test.ts
Normal file
176
apps/cli/src/tools/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
apps/cli/src/tools/index.ts
Normal file
51
apps/cli/src/tools/index.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
237
apps/cli/src/tools/shell.test.ts
Normal file
237
apps/cli/src/tools/shell.test.ts
Normal 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
233
apps/cli/src/tools/shell.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
155
apps/cli/src/utils/logger.test.ts
Normal file
155
apps/cli/src/utils/logger.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
65
apps/cli/src/utils/logger.ts
Normal file
65
apps/cli/src/utils/logger.ts
Normal 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
20
apps/cli/tsconfig.json
Normal 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"]
|
||||
}
|
||||
23
apps/cli/vitest.config.mts
Normal file
23
apps/cli/vitest.config.mts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
20
packages/device-gateway-client/package.json
Normal file
20
packages/device-gateway-client/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@lobechat/device-gateway-client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
"test": "bunx vitest run --silent='passed-only'",
|
||||
"test:coverage": "bunx vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.18.1",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
523
packages/device-gateway-client/src/client.test.ts
Normal file
523
packages/device-gateway-client/src/client.test.ts
Normal file
@@ -0,0 +1,523 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GatewayClient } from './client';
|
||||
|
||||
// Flag to control mock WS behavior
|
||||
let mockWsShouldThrow = false;
|
||||
|
||||
// Mock ws module — must use dynamic import for EventEmitter to avoid hoisting issues
|
||||
vi.mock('ws', async () => {
|
||||
const { EventEmitter } = await import('node:events');
|
||||
class MockWebSocket extends EventEmitter {
|
||||
static OPEN = 1;
|
||||
static CONNECTING = 0;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
readyState = 1; // OPEN
|
||||
|
||||
constructor(public url: string) {
|
||||
super();
|
||||
if (mockWsShouldThrow) {
|
||||
mockWsShouldThrow = false;
|
||||
throw new Error('connection refused');
|
||||
}
|
||||
// Simulate async open
|
||||
setTimeout(() => this.emit('open'), 0);
|
||||
}
|
||||
|
||||
send = vi.fn();
|
||||
close = vi.fn();
|
||||
override removeAllListeners = vi.fn(() => {
|
||||
return this;
|
||||
});
|
||||
}
|
||||
return { default: MockWebSocket };
|
||||
});
|
||||
|
||||
// Mock os
|
||||
vi.mock('node:os', () => ({
|
||||
default: {
|
||||
hostname: () => 'test-host',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('GatewayClient', () => {
|
||||
let client: GatewayClient;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
client = new GatewayClient({
|
||||
autoReconnect: false,
|
||||
deviceId: 'test-device-id',
|
||||
gatewayUrl: 'https://gateway.test.com',
|
||||
token: 'test-token',
|
||||
userId: 'test-user',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.disconnect();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should set default values', () => {
|
||||
const c = new GatewayClient({ token: 'tok' });
|
||||
expect(c.connectionStatus).toBe('disconnected');
|
||||
expect(c.currentDeviceId).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use provided options', () => {
|
||||
expect(client.currentDeviceId).toBe('test-device-id');
|
||||
expect(client.connectionStatus).toBe('disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('connect', () => {
|
||||
it('should transition to connecting then authenticating on open', async () => {
|
||||
const statusChanges: string[] = [];
|
||||
client.on('status_changed', (s) => statusChanges.push(s));
|
||||
|
||||
client.connect();
|
||||
expect(client.connectionStatus).toBe('connecting');
|
||||
|
||||
// Let the mock WebSocket emit 'open'
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(client.connectionStatus).toBe('authenticating');
|
||||
expect(statusChanges).toContain('connecting');
|
||||
expect(statusChanges).toContain('authenticating');
|
||||
});
|
||||
|
||||
it('should not reconnect if already connected', async () => {
|
||||
client.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
// Simulate auth success
|
||||
const handler = (client as any).handleMessage;
|
||||
handler(JSON.stringify({ type: 'auth_success' }));
|
||||
|
||||
expect(client.connectionStatus).toBe('connected');
|
||||
|
||||
// Calling connect again should be a no-op
|
||||
client.connect();
|
||||
expect(client.connectionStatus).toBe('connected');
|
||||
});
|
||||
|
||||
it('should not reconnect if connecting', () => {
|
||||
client.connect();
|
||||
expect(client.connectionStatus).toBe('connecting');
|
||||
client.connect(); // no-op
|
||||
expect(client.connectionStatus).toBe('connecting');
|
||||
});
|
||||
|
||||
it('should build correct WebSocket URL with https', () => {
|
||||
client.connect();
|
||||
const ws = (client as any).ws;
|
||||
expect(ws.url).toContain('wss://gateway.test.com/ws');
|
||||
expect(ws.url).toContain('deviceId=test-device-id');
|
||||
expect(ws.url).toContain('hostname=test-host');
|
||||
expect(ws.url).toContain('userId=test-user');
|
||||
});
|
||||
|
||||
it('should build ws URL for http gateway', () => {
|
||||
const c = new GatewayClient({
|
||||
autoReconnect: false,
|
||||
gatewayUrl: 'http://localhost:3000',
|
||||
token: 'tok',
|
||||
});
|
||||
c.connect();
|
||||
const ws = (c as any).ws;
|
||||
expect(ws.url).toContain('ws://localhost:3000/ws');
|
||||
c.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message handling', () => {
|
||||
let handler: (data: any) => void;
|
||||
|
||||
beforeEach(async () => {
|
||||
client.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
handler = (client as any).handleMessage;
|
||||
});
|
||||
|
||||
it('should handle auth_success', () => {
|
||||
const connectedCb = vi.fn();
|
||||
client.on('connected', connectedCb);
|
||||
|
||||
handler(JSON.stringify({ type: 'auth_success' }));
|
||||
|
||||
expect(client.connectionStatus).toBe('connected');
|
||||
expect(connectedCb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle auth_failed', () => {
|
||||
const authFailedCb = vi.fn();
|
||||
client.on('auth_failed', authFailedCb);
|
||||
|
||||
handler(JSON.stringify({ type: 'auth_failed', reason: 'invalid token' }));
|
||||
|
||||
expect(authFailedCb).toHaveBeenCalledWith('invalid token');
|
||||
});
|
||||
|
||||
it('should handle auth_failed with no reason', () => {
|
||||
const authFailedCb = vi.fn();
|
||||
client.on('auth_failed', authFailedCb);
|
||||
|
||||
handler(JSON.stringify({ type: 'auth_failed' }));
|
||||
|
||||
expect(authFailedCb).toHaveBeenCalledWith('Unknown reason');
|
||||
});
|
||||
|
||||
it('should handle heartbeat_ack', () => {
|
||||
const heartbeatCb = vi.fn();
|
||||
client.on('heartbeat_ack', heartbeatCb);
|
||||
|
||||
handler(JSON.stringify({ type: 'heartbeat_ack' }));
|
||||
|
||||
expect(heartbeatCb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle tool_call_request', () => {
|
||||
const toolCallCb = vi.fn();
|
||||
client.on('tool_call_request', toolCallCb);
|
||||
|
||||
const msg = {
|
||||
type: 'tool_call_request',
|
||||
requestId: 'req-1',
|
||||
toolCall: { apiName: 'readFile', arguments: '{}', identifier: 'test' },
|
||||
};
|
||||
handler(JSON.stringify(msg));
|
||||
|
||||
expect(toolCallCb).toHaveBeenCalledWith(msg);
|
||||
});
|
||||
|
||||
it('should handle system_info_request', () => {
|
||||
const sysInfoCb = vi.fn();
|
||||
client.on('system_info_request', sysInfoCb);
|
||||
|
||||
const msg = { type: 'system_info_request', requestId: 'req-2' };
|
||||
handler(JSON.stringify(msg));
|
||||
|
||||
expect(sysInfoCb).toHaveBeenCalledWith(msg);
|
||||
});
|
||||
|
||||
it('should handle auth_expired', () => {
|
||||
const expiredCb = vi.fn();
|
||||
client.on('auth_expired', expiredCb);
|
||||
|
||||
handler(JSON.stringify({ type: 'auth_expired' }));
|
||||
|
||||
expect(expiredCb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle unknown message type', () => {
|
||||
// Should not throw
|
||||
handler(JSON.stringify({ type: 'unknown_type' }));
|
||||
});
|
||||
|
||||
it('should handle invalid JSON', () => {
|
||||
// Should not throw
|
||||
handler('not json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('should set status to disconnected', async () => {
|
||||
client.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
await client.disconnect();
|
||||
|
||||
expect(client.connectionStatus).toBe('disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendToolCallResponse', () => {
|
||||
it('should send tool call response message', async () => {
|
||||
client.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
const ws = (client as any).ws;
|
||||
client.sendToolCallResponse({
|
||||
requestId: 'req-1',
|
||||
result: { content: 'result', success: true },
|
||||
});
|
||||
|
||||
expect(ws.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
requestId: 'req-1',
|
||||
result: { content: 'result', success: true },
|
||||
type: 'tool_call_response',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendSystemInfoResponse', () => {
|
||||
it('should send system info response message', async () => {
|
||||
client.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
const ws = (client as any).ws;
|
||||
client.sendSystemInfoResponse({
|
||||
requestId: 'req-2',
|
||||
result: {
|
||||
success: true,
|
||||
systemInfo: {
|
||||
arch: 'x64',
|
||||
desktopPath: '/home/test/Desktop',
|
||||
documentsPath: '/home/test/Documents',
|
||||
downloadsPath: '/home/test/Downloads',
|
||||
homePath: '/home/test',
|
||||
musicPath: '/home/test/Music',
|
||||
picturesPath: '/home/test/Pictures',
|
||||
userDataPath: '/home/test/.lobehub',
|
||||
videosPath: '/home/test/Videos',
|
||||
workingDirectory: '/home/test',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(ws.send).toHaveBeenCalled();
|
||||
const sentData = JSON.parse(ws.send.mock.calls.at(-1)[0]);
|
||||
expect(sentData.type).toBe('system_info_response');
|
||||
expect(sentData.requestId).toBe('req-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessage when ws not open', () => {
|
||||
it('should not send when ws is null', () => {
|
||||
// Not connected, ws is null
|
||||
client.sendToolCallResponse({
|
||||
requestId: 'req-1',
|
||||
result: { content: 'result', success: true },
|
||||
});
|
||||
// Should not throw
|
||||
});
|
||||
|
||||
it('should not send when ws is not OPEN', async () => {
|
||||
client.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
const ws = (client as any).ws;
|
||||
ws.readyState = 3; // CLOSED
|
||||
|
||||
client.sendToolCallResponse({
|
||||
requestId: 'req-1',
|
||||
result: { content: 'result', success: true },
|
||||
});
|
||||
|
||||
// send should not have been called after auth message
|
||||
// (auth send happens when readyState was OPEN)
|
||||
const calls = ws.send.mock.calls;
|
||||
// Only the auth message was sent
|
||||
expect(calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('heartbeat', () => {
|
||||
it('should send heartbeat after connection', async () => {
|
||||
client.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
const handler = (client as any).handleMessage;
|
||||
handler(JSON.stringify({ type: 'auth_success' }));
|
||||
|
||||
const ws = (client as any).ws;
|
||||
ws.send.mockClear();
|
||||
|
||||
// Advance 30 seconds for heartbeat
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ type: 'heartbeat' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconnection', () => {
|
||||
it('should reconnect on close when autoReconnect is true', async () => {
|
||||
const reconnectClient = new GatewayClient({
|
||||
autoReconnect: true,
|
||||
gatewayUrl: 'https://gateway.test.com',
|
||||
token: 'tok',
|
||||
});
|
||||
const reconnectingCb = vi.fn();
|
||||
reconnectClient.on('reconnecting', reconnectingCb);
|
||||
|
||||
reconnectClient.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
// Simulate close
|
||||
const closeHandler = (reconnectClient as any).handleClose;
|
||||
closeHandler(1000, Buffer.from('normal'));
|
||||
|
||||
expect(reconnectClient.connectionStatus).toBe('reconnecting');
|
||||
expect(reconnectingCb).toHaveBeenCalledWith(1000); // initial delay
|
||||
|
||||
reconnectClient.disconnect();
|
||||
});
|
||||
|
||||
it('should not reconnect on intentional disconnect', async () => {
|
||||
const reconnectClient = new GatewayClient({
|
||||
autoReconnect: true,
|
||||
gatewayUrl: 'https://gateway.test.com',
|
||||
token: 'tok',
|
||||
});
|
||||
|
||||
reconnectClient.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
await reconnectClient.disconnect();
|
||||
|
||||
const disconnectedCb = vi.fn();
|
||||
reconnectClient.on('disconnected', disconnectedCb);
|
||||
|
||||
// handleClose called after disconnect
|
||||
const closeHandler = (reconnectClient as any).handleClose;
|
||||
closeHandler(1000, Buffer.from(''));
|
||||
|
||||
expect(reconnectClient.connectionStatus).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('should use exponential backoff', async () => {
|
||||
const reconnectClient = new GatewayClient({
|
||||
autoReconnect: true,
|
||||
gatewayUrl: 'https://gateway.test.com',
|
||||
token: 'tok',
|
||||
});
|
||||
const delays: number[] = [];
|
||||
reconnectClient.on('reconnecting', (delay) => delays.push(delay));
|
||||
|
||||
reconnectClient.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
// First close → scheduleReconnect with delay=1000, then reconnectDelay doubles to 2000
|
||||
const closeHandler = (reconnectClient as any).handleClose;
|
||||
closeHandler(1000, Buffer.from(''));
|
||||
expect(delays[0]).toBe(1000);
|
||||
|
||||
// Advance to trigger reconnect → doConnect → new WS → 'open' fires → reconnectDelay resets to 1000
|
||||
// Then close again → scheduleReconnect with delay=1000 (reset by handleOpen)
|
||||
// To test true backoff, we need closes before 'open' fires.
|
||||
// Instead, verify the internal reconnectDelay doubles after scheduleReconnect
|
||||
expect((reconnectClient as any).reconnectDelay).toBe(2000);
|
||||
|
||||
// Second close without letting open fire first
|
||||
closeHandler(1000, Buffer.from(''));
|
||||
expect(delays[1]).toBe(2000);
|
||||
expect((reconnectClient as any).reconnectDelay).toBe(4000);
|
||||
|
||||
closeHandler(1000, Buffer.from(''));
|
||||
expect(delays[2]).toBe(4000);
|
||||
expect((reconnectClient as any).reconnectDelay).toBe(8000);
|
||||
|
||||
reconnectClient.disconnect();
|
||||
});
|
||||
|
||||
it('should emit disconnected when autoReconnect is false and ws closes', async () => {
|
||||
const disconnectedCb = vi.fn();
|
||||
client.on('disconnected', disconnectedCb);
|
||||
|
||||
client.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
const closeHandler = (client as any).handleClose;
|
||||
closeHandler(1000, Buffer.from(''));
|
||||
|
||||
expect(disconnectedCb).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleError', () => {
|
||||
it('should emit error event', async () => {
|
||||
const errorCb = vi.fn();
|
||||
client.on('error', errorCb);
|
||||
|
||||
client.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
const errorHandler = (client as any).handleError;
|
||||
errorHandler(new Error('test error'));
|
||||
|
||||
expect(errorCb).toHaveBeenCalledWith(expect.objectContaining({ message: 'test error' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('doConnect error', () => {
|
||||
it('should handle WebSocket constructor error with autoReconnect false', () => {
|
||||
mockWsShouldThrow = true;
|
||||
|
||||
const disconnectedCb = vi.fn();
|
||||
const c = new GatewayClient({
|
||||
autoReconnect: false,
|
||||
gatewayUrl: 'https://gateway.test.com',
|
||||
token: 'tok',
|
||||
});
|
||||
c.on('disconnected', disconnectedCb);
|
||||
|
||||
c.connect();
|
||||
|
||||
expect(c.connectionStatus).toBe('disconnected');
|
||||
expect(disconnectedCb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should schedule reconnect on constructor error with autoReconnect true', () => {
|
||||
mockWsShouldThrow = true;
|
||||
|
||||
const reconnectingCb = vi.fn();
|
||||
const c = new GatewayClient({
|
||||
autoReconnect: true,
|
||||
gatewayUrl: 'https://gateway.test.com',
|
||||
token: 'tok',
|
||||
});
|
||||
c.on('reconnecting', reconnectingCb);
|
||||
|
||||
c.connect();
|
||||
|
||||
expect(reconnectingCb).toHaveBeenCalled();
|
||||
c.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setStatus no-op for same status', () => {
|
||||
it('should not emit status_changed if status is the same', () => {
|
||||
const statusCb = vi.fn();
|
||||
client.on('status_changed', statusCb);
|
||||
|
||||
// Call setStatus with 'disconnected' (already the current status)
|
||||
(client as any).setStatus('disconnected');
|
||||
|
||||
expect(statusCb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeWebSocket edge cases', () => {
|
||||
it('should handle ws in CONNECTING state', async () => {
|
||||
client.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
const ws = (client as any).ws;
|
||||
ws.readyState = 0; // CONNECTING
|
||||
ws.close = vi.fn();
|
||||
ws.removeAllListeners = vi.fn();
|
||||
|
||||
(client as any).closeWebSocket();
|
||||
expect(ws.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle ws in CLOSED state', async () => {
|
||||
client.connect();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
const ws = (client as any).ws;
|
||||
ws.readyState = 3; // CLOSED
|
||||
ws.close = vi.fn();
|
||||
ws.removeAllListeners = vi.fn();
|
||||
|
||||
(client as any).closeWebSocket();
|
||||
expect(ws.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
331
packages/device-gateway-client/src/client.ts
Normal file
331
packages/device-gateway-client/src/client.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import os from 'node:os';
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import type {
|
||||
ClientMessage,
|
||||
ConnectionStatus,
|
||||
GatewayClientEvents,
|
||||
ServerMessage,
|
||||
SystemInfoRequestMessage,
|
||||
SystemInfoResponseMessage,
|
||||
ToolCallRequestMessage,
|
||||
ToolCallResponseMessage,
|
||||
} from './types';
|
||||
|
||||
// ─── Constants ───
|
||||
|
||||
const DEFAULT_GATEWAY_URL = 'https://device-gateway.lobehub.com';
|
||||
const HEARTBEAT_INTERVAL = 30_000; // 30s
|
||||
const INITIAL_RECONNECT_DELAY = 1000; // 1s
|
||||
const MAX_RECONNECT_DELAY = 30_000; // 30s
|
||||
|
||||
// ─── Logger Interface ───
|
||||
|
||||
export interface GatewayClientLogger {
|
||||
debug: (msg: string, ...args: unknown[]) => void;
|
||||
error: (msg: string, ...args: unknown[]) => void;
|
||||
info: (msg: string, ...args: unknown[]) => void;
|
||||
warn: (msg: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
const noopLogger: GatewayClientLogger = {
|
||||
debug: () => {},
|
||||
error: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
};
|
||||
|
||||
export interface GatewayClientOptions {
|
||||
/** Auto-reconnect on disconnection (default: true) */
|
||||
autoReconnect?: boolean;
|
||||
deviceId?: string;
|
||||
gatewayUrl?: string;
|
||||
logger?: GatewayClientLogger;
|
||||
token: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class GatewayClient extends EventEmitter {
|
||||
private ws: WebSocket | null = null;
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
private status: ConnectionStatus = 'disconnected';
|
||||
private intentionalDisconnect = false;
|
||||
private deviceId: string;
|
||||
private gatewayUrl: string;
|
||||
private token: string;
|
||||
private userId?: string;
|
||||
private logger: GatewayClientLogger;
|
||||
private autoReconnect: boolean;
|
||||
|
||||
constructor(options: GatewayClientOptions) {
|
||||
super();
|
||||
this.token = options.token;
|
||||
this.gatewayUrl = options.gatewayUrl || DEFAULT_GATEWAY_URL;
|
||||
this.deviceId = options.deviceId || randomUUID();
|
||||
this.userId = options.userId;
|
||||
this.logger = options.logger || noopLogger;
|
||||
this.autoReconnect = options.autoReconnect ?? true;
|
||||
}
|
||||
|
||||
// ─── Public API ───
|
||||
|
||||
get connectionStatus(): ConnectionStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
get currentDeviceId(): string {
|
||||
return this.deviceId;
|
||||
}
|
||||
|
||||
override on<K extends keyof GatewayClientEvents>(
|
||||
event: K,
|
||||
listener: GatewayClientEvents[K],
|
||||
): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
override emit<K extends keyof GatewayClientEvents>(
|
||||
event: K,
|
||||
...args: Parameters<GatewayClientEvents[K]>
|
||||
): boolean {
|
||||
return super.emit(event, ...args);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.status === 'connected' || this.status === 'connecting') {
|
||||
return;
|
||||
}
|
||||
this.intentionalDisconnect = false;
|
||||
this.doConnect();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.intentionalDisconnect = true;
|
||||
this.cleanup();
|
||||
this.setStatus('disconnected');
|
||||
}
|
||||
|
||||
sendToolCallResponse(response: Omit<ToolCallResponseMessage, 'type'>): void {
|
||||
this.sendMessage({
|
||||
...response,
|
||||
type: 'tool_call_response',
|
||||
});
|
||||
}
|
||||
|
||||
sendSystemInfoResponse(response: Omit<SystemInfoResponseMessage, 'type'>): void {
|
||||
this.sendMessage({
|
||||
...response,
|
||||
type: 'system_info_response',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Connection Logic ───
|
||||
|
||||
private doConnect() {
|
||||
this.clearReconnectTimer();
|
||||
|
||||
this.setStatus('connecting');
|
||||
|
||||
try {
|
||||
const wsUrl = this.buildWsUrl();
|
||||
this.logger.debug(`Connecting to: ${wsUrl}`);
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.on('open', this.handleOpen);
|
||||
ws.on('message', this.handleMessage);
|
||||
ws.on('close', this.handleClose);
|
||||
ws.on('error', this.handleError);
|
||||
|
||||
this.ws = ws;
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error('Failed to create WebSocket:', msg);
|
||||
this.setStatus('disconnected');
|
||||
if (this.autoReconnect) {
|
||||
this.scheduleReconnect();
|
||||
} else {
|
||||
this.emit('disconnected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildWsUrl(): string {
|
||||
const wsProtocol = this.gatewayUrl.startsWith('https') ? 'wss' : 'ws';
|
||||
const host = this.gatewayUrl.replace(/^https?:\/\//, '');
|
||||
const params = new URLSearchParams({
|
||||
deviceId: this.deviceId,
|
||||
hostname: os.hostname(),
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
// Service token mode: pass userId in query
|
||||
if (this.userId) {
|
||||
params.set('userId', this.userId);
|
||||
}
|
||||
|
||||
return `${wsProtocol}://${host}/ws?${params.toString()}`;
|
||||
}
|
||||
|
||||
// ─── WebSocket Event Handlers ───
|
||||
|
||||
private handleOpen = () => {
|
||||
this.logger.info('WebSocket connected, sending auth...');
|
||||
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
this.setStatus('authenticating');
|
||||
|
||||
// Send token as first message instead of in URL
|
||||
this.sendMessage({ type: 'auth', token: this.token });
|
||||
};
|
||||
|
||||
private handleMessage = (data: WebSocket.Data) => {
|
||||
try {
|
||||
const message = JSON.parse(String(data)) as ServerMessage;
|
||||
|
||||
switch (message.type) {
|
||||
case 'auth_success': {
|
||||
this.logger.info('Authentication successful');
|
||||
this.setStatus('connected');
|
||||
this.startHeartbeat();
|
||||
this.emit('connected');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'auth_failed': {
|
||||
const reason = (message as any).reason || 'Unknown reason';
|
||||
this.logger.error(`Authentication failed: ${reason}`);
|
||||
this.emit('auth_failed', reason);
|
||||
this.disconnect();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'heartbeat_ack': {
|
||||
this.emit('heartbeat_ack');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_call_request': {
|
||||
this.emit('tool_call_request', message as ToolCallRequestMessage);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'system_info_request': {
|
||||
this.emit('system_info_request', message as SystemInfoRequestMessage);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'auth_expired': {
|
||||
this.logger.warn('Received auth_expired from gateway');
|
||||
this.emit('auth_expired');
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
this.logger.warn('Unknown message type:', (message as any).type);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to parse WebSocket message:', error as string);
|
||||
}
|
||||
};
|
||||
|
||||
private handleClose = (code: number, reason: Buffer) => {
|
||||
this.logger.info(`WebSocket closed: code=${code} reason=${reason.toString()}`);
|
||||
this.stopHeartbeat();
|
||||
this.ws = null;
|
||||
|
||||
if (!this.intentionalDisconnect && this.autoReconnect) {
|
||||
this.setStatus('reconnecting');
|
||||
this.scheduleReconnect();
|
||||
} else {
|
||||
this.setStatus('disconnected');
|
||||
this.emit('disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
private handleError = (error: Error) => {
|
||||
this.logger.error('WebSocket error:', error.message);
|
||||
this.emit('error', error);
|
||||
};
|
||||
|
||||
// ─── Heartbeat ───
|
||||
|
||||
private startHeartbeat() {
|
||||
this.stopHeartbeat();
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.sendMessage({ type: 'heartbeat' });
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
}
|
||||
|
||||
private stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Reconnection (exponential backoff) ───
|
||||
|
||||
private scheduleReconnect() {
|
||||
this.clearReconnectTimer();
|
||||
|
||||
const delay = this.reconnectDelay;
|
||||
this.logger.info(`Scheduling reconnect in ${delay}ms`);
|
||||
this.emit('reconnecting', delay);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.logger.info('Attempting reconnect');
|
||||
this.doConnect();
|
||||
}, delay);
|
||||
|
||||
// Exponential backoff: 1s → 2s → 4s → 8s → ... → 30s
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
}
|
||||
|
||||
private clearReconnectTimer() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Status ───
|
||||
|
||||
private setStatus(status: ConnectionStatus) {
|
||||
if (this.status === status) return;
|
||||
|
||||
this.status = status;
|
||||
this.emit('status_changed', status);
|
||||
}
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
private sendMessage(data: ClientMessage) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
private closeWebSocket() {
|
||||
if (this.ws) {
|
||||
this.ws.removeAllListeners();
|
||||
|
||||
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
||||
this.ws.close(1000, 'Client disconnect');
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
this.stopHeartbeat();
|
||||
this.clearReconnectTimer();
|
||||
this.closeWebSocket();
|
||||
}
|
||||
}
|
||||
282
packages/device-gateway-client/src/http.test.ts
Normal file
282
packages/device-gateway-client/src/http.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GatewayHttpClient } from './http';
|
||||
|
||||
describe('GatewayHttpClient', () => {
|
||||
let client: GatewayHttpClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new GatewayHttpClient({
|
||||
gatewayUrl: 'https://gateway.test.com',
|
||||
serviceToken: 'test-service-token',
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mockFetch(response: Partial<Response>) {
|
||||
const res = {
|
||||
json: vi.fn().mockResolvedValue(response.json ? response.json() : {}),
|
||||
ok: response.ok ?? true,
|
||||
status: response.status ?? 200,
|
||||
text: vi.fn().mockResolvedValue(''),
|
||||
...response,
|
||||
};
|
||||
// Re-bind json/text if the response object had them
|
||||
if ('json' in response && typeof response.json === 'function') {
|
||||
res.json = response.json;
|
||||
}
|
||||
if ('text' in response && typeof response.text === 'function') {
|
||||
res.text = response.text;
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValue(res as any);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('queryDeviceStatus', () => {
|
||||
it('should return device status on success', async () => {
|
||||
mockFetch({
|
||||
json: vi.fn().mockResolvedValue({ deviceCount: 2, online: true }),
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const result = await client.queryDeviceStatus('user-1');
|
||||
|
||||
expect(result).toEqual({ deviceCount: 2, online: true });
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://gateway.test.com/api/device/status',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ userId: 'user-1' }),
|
||||
headers: {
|
||||
'Authorization': 'Bearer test-service-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return defaults on non-ok response', async () => {
|
||||
mockFetch({ ok: false, status: 500 });
|
||||
|
||||
const result = await client.queryDeviceStatus('user-1');
|
||||
|
||||
expect(result).toEqual({ deviceCount: 0, online: false });
|
||||
});
|
||||
|
||||
it('should handle missing fields in response', async () => {
|
||||
mockFetch({
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const result = await client.queryDeviceStatus('user-1');
|
||||
|
||||
expect(result).toEqual({ deviceCount: 0, online: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryDeviceList', () => {
|
||||
it('should return device list on success', async () => {
|
||||
const devices = [
|
||||
{ connectedAt: 1000, deviceId: 'd1', hostname: 'host1', platform: 'darwin' },
|
||||
];
|
||||
mockFetch({
|
||||
json: vi.fn().mockResolvedValue({ devices }),
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const result = await client.queryDeviceList('user-1');
|
||||
|
||||
expect(result).toEqual(devices);
|
||||
});
|
||||
|
||||
it('should return empty array on non-ok response', async () => {
|
||||
mockFetch({ ok: false });
|
||||
|
||||
const result = await client.queryDeviceList('user-1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when devices is not an array', async () => {
|
||||
mockFetch({
|
||||
json: vi.fn().mockResolvedValue({ devices: 'not-array' }),
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const result = await client.queryDeviceList('user-1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when devices is missing', async () => {
|
||||
mockFetch({
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const result = await client.queryDeviceList('user-1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeToolCall', () => {
|
||||
it('should return tool call result on success', async () => {
|
||||
mockFetch({
|
||||
json: vi.fn().mockResolvedValue({ content: 'file contents', success: true }),
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const result = await client.executeToolCall(
|
||||
{ userId: 'user-1' },
|
||||
{ apiName: 'readFile', arguments: '{}', identifier: 'test' },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ content: 'file contents', error: undefined, success: true });
|
||||
});
|
||||
|
||||
it('should handle non-string content', async () => {
|
||||
mockFetch({
|
||||
json: vi.fn().mockResolvedValue({ content: { key: 'value' }, success: true }),
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const result = await client.executeToolCall(
|
||||
{ userId: 'user-1' },
|
||||
{ apiName: 'readFile', arguments: '{}', identifier: 'test' },
|
||||
);
|
||||
|
||||
expect(result.content).toBe(JSON.stringify({ key: 'value' }));
|
||||
});
|
||||
|
||||
it('should handle null/undefined content', async () => {
|
||||
mockFetch({
|
||||
json: vi.fn().mockResolvedValue({ success: true }),
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const result = await client.executeToolCall(
|
||||
{ userId: 'user-1' },
|
||||
{ apiName: 'readFile', arguments: '{}', identifier: 'test' },
|
||||
);
|
||||
|
||||
// content is undefined, so JSON.stringify(undefined ?? data) -> JSON.stringify(data)
|
||||
expect(result.content).toContain('success');
|
||||
});
|
||||
|
||||
it('should handle missing success field', async () => {
|
||||
mockFetch({
|
||||
json: vi.fn().mockResolvedValue({ content: 'ok' }),
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const result = await client.executeToolCall(
|
||||
{ userId: 'user-1' },
|
||||
{ apiName: 'readFile', arguments: '{}', identifier: 'test' },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle non-ok response', async () => {
|
||||
mockFetch({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: vi.fn().mockResolvedValue('Internal Server Error'),
|
||||
});
|
||||
|
||||
const result = await client.executeToolCall(
|
||||
{ userId: 'user-1' },
|
||||
{ apiName: 'readFile', arguments: '{}', identifier: 'test' },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Internal Server Error');
|
||||
expect(result.content).toContain('HTTP 500');
|
||||
});
|
||||
|
||||
it('should handle non-ok response with text() failure', async () => {
|
||||
mockFetch({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: vi.fn().mockRejectedValue(new Error('read error')),
|
||||
});
|
||||
|
||||
const result = await client.executeToolCall(
|
||||
{ userId: 'user-1' },
|
||||
{ apiName: 'readFile', arguments: '{}', identifier: 'test' },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('HTTP 500');
|
||||
});
|
||||
|
||||
it('should pass optional deviceId and timeout', async () => {
|
||||
mockFetch({
|
||||
json: vi.fn().mockResolvedValue({ content: 'ok', success: true }),
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await client.executeToolCall(
|
||||
{ deviceId: 'device-1', timeout: 5000, userId: 'user-1' },
|
||||
{ apiName: 'readFile', arguments: '{}', identifier: 'test' },
|
||||
);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://gateway.test.com/api/device/tool-call',
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining('"deviceId":"device-1"'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeviceSystemInfo', () => {
|
||||
it('should return system info on success', async () => {
|
||||
const systemInfo = {
|
||||
arch: 'x64',
|
||||
desktopPath: '/home/test/Desktop',
|
||||
documentsPath: '/home/test/Documents',
|
||||
downloadsPath: '/home/test/Downloads',
|
||||
homePath: '/home/test',
|
||||
musicPath: '/home/test/Music',
|
||||
picturesPath: '/home/test/Pictures',
|
||||
userDataPath: '/home/test/.lobehub',
|
||||
videosPath: '/home/test/Videos',
|
||||
workingDirectory: '/home/test',
|
||||
};
|
||||
mockFetch({
|
||||
json: vi.fn().mockResolvedValue({ success: true, systemInfo }),
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const result = await client.getDeviceSystemInfo('user-1', 'device-1');
|
||||
|
||||
expect(result).toEqual({ success: true, systemInfo });
|
||||
});
|
||||
|
||||
it('should return failure on non-ok response', async () => {
|
||||
mockFetch({ ok: false });
|
||||
|
||||
const result = await client.getDeviceSystemInfo('user-1', 'device-1');
|
||||
|
||||
expect(result).toEqual({ success: false });
|
||||
});
|
||||
|
||||
it('should handle missing success field', async () => {
|
||||
mockFetch({
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const result = await client.getDeviceSystemInfo('user-1', 'device-1');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
102
packages/device-gateway-client/src/http.ts
Normal file
102
packages/device-gateway-client/src/http.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { DeviceAttachment, DeviceSystemInfo } from './types';
|
||||
|
||||
export interface DeviceStatusResult {
|
||||
deviceCount: number;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export interface DeviceToolCallResult {
|
||||
content: string;
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface GatewayHttpClientOptions {
|
||||
gatewayUrl: string;
|
||||
serviceToken: string;
|
||||
}
|
||||
|
||||
export class GatewayHttpClient {
|
||||
private gatewayUrl: string;
|
||||
private serviceToken: string;
|
||||
|
||||
constructor(options: GatewayHttpClientOptions) {
|
||||
this.gatewayUrl = options.gatewayUrl;
|
||||
this.serviceToken = options.serviceToken;
|
||||
}
|
||||
|
||||
async queryDeviceStatus(userId: string): Promise<DeviceStatusResult> {
|
||||
const res = await this.post('/api/device/status', { userId });
|
||||
if (!res.ok) return { deviceCount: 0, online: false };
|
||||
|
||||
const data = await res.json();
|
||||
return {
|
||||
deviceCount: data.deviceCount ?? 0,
|
||||
online: data.online ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
async queryDeviceList(userId: string): Promise<DeviceAttachment[]> {
|
||||
const res = await this.post('/api/device/devices', { userId });
|
||||
if (!res.ok) return [];
|
||||
|
||||
const data = await res.json();
|
||||
return Array.isArray(data.devices) ? data.devices : [];
|
||||
}
|
||||
|
||||
async executeToolCall(
|
||||
params: { deviceId?: string; timeout?: number; userId: string },
|
||||
toolCall: { apiName: string; arguments: string; identifier: string },
|
||||
): Promise<DeviceToolCallResult> {
|
||||
const res = await this.post('/api/device/tool-call', {
|
||||
deviceId: params.deviceId,
|
||||
timeout: params.timeout,
|
||||
toolCall,
|
||||
userId: params.userId,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
return {
|
||||
content: `Device tool call failed (HTTP ${res.status})`,
|
||||
error: text || `HTTP ${res.status}`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return {
|
||||
content:
|
||||
typeof data.content === 'string' ? data.content : JSON.stringify(data.content ?? data),
|
||||
error: data.error,
|
||||
success: data.success ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
async getDeviceSystemInfo(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
): Promise<{ success: boolean; systemInfo?: DeviceSystemInfo }> {
|
||||
const res = await this.post('/api/device/system-info', { deviceId, userId });
|
||||
if (!res.ok) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return {
|
||||
success: data.success ?? false,
|
||||
systemInfo: data.systemInfo,
|
||||
};
|
||||
}
|
||||
|
||||
private post(path: string, body: unknown): Promise<Response> {
|
||||
return fetch(`${this.gatewayUrl}${path}`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.serviceToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
}
|
||||
5
packages/device-gateway-client/src/index.ts
Normal file
5
packages/device-gateway-client/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { GatewayClientLogger, GatewayClientOptions } from './client';
|
||||
export { GatewayClient } from './client';
|
||||
export type { DeviceStatusResult, DeviceToolCallResult, GatewayHttpClientOptions } from './http';
|
||||
export { GatewayHttpClient } from './http';
|
||||
export * from './types';
|
||||
122
packages/device-gateway-client/src/types.ts
Normal file
122
packages/device-gateway-client/src/types.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// ─── Device Info ───
|
||||
|
||||
export interface DeviceAttachment {
|
||||
connectedAt: number;
|
||||
deviceId: string;
|
||||
hostname: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export interface DeviceSystemInfo {
|
||||
arch: string;
|
||||
desktopPath: string;
|
||||
documentsPath: string;
|
||||
downloadsPath: string;
|
||||
homePath: string;
|
||||
musicPath: string;
|
||||
picturesPath: string;
|
||||
userDataPath: string;
|
||||
videosPath: string;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
// ─── WebSocket Protocol Messages (mirrors apps/device-gateway/src/types.ts) ───
|
||||
|
||||
// Client → Server
|
||||
export interface AuthMessage {
|
||||
token: string;
|
||||
type: 'auth';
|
||||
}
|
||||
|
||||
export interface HeartbeatMessage {
|
||||
type: 'heartbeat';
|
||||
}
|
||||
|
||||
export interface ToolCallResponseMessage {
|
||||
requestId: string;
|
||||
result: {
|
||||
content: string;
|
||||
error?: string;
|
||||
success: boolean;
|
||||
};
|
||||
type: 'tool_call_response';
|
||||
}
|
||||
|
||||
// Server → Client
|
||||
export interface HeartbeatAckMessage {
|
||||
type: 'heartbeat_ack';
|
||||
}
|
||||
|
||||
export interface AuthSuccessMessage {
|
||||
type: 'auth_success';
|
||||
}
|
||||
|
||||
export interface AuthFailedMessage {
|
||||
reason: string;
|
||||
type: 'auth_failed';
|
||||
}
|
||||
|
||||
export interface AuthExpiredMessage {
|
||||
type: 'auth_expired';
|
||||
}
|
||||
|
||||
export interface ToolCallRequestMessage {
|
||||
requestId: string;
|
||||
toolCall: {
|
||||
apiName: string;
|
||||
arguments: string;
|
||||
identifier: string;
|
||||
};
|
||||
type: 'tool_call_request';
|
||||
}
|
||||
|
||||
// Server → Client
|
||||
export interface SystemInfoRequestMessage {
|
||||
requestId: string;
|
||||
type: 'system_info_request';
|
||||
}
|
||||
|
||||
// Client → Server
|
||||
export interface SystemInfoResponseMessage {
|
||||
requestId: string;
|
||||
result: {
|
||||
success: boolean;
|
||||
systemInfo: DeviceSystemInfo;
|
||||
};
|
||||
type: 'system_info_response';
|
||||
}
|
||||
|
||||
export type ClientMessage =
|
||||
| AuthMessage
|
||||
| HeartbeatMessage
|
||||
| SystemInfoResponseMessage
|
||||
| ToolCallResponseMessage;
|
||||
export type ServerMessage =
|
||||
| AuthExpiredMessage
|
||||
| AuthFailedMessage
|
||||
| AuthSuccessMessage
|
||||
| HeartbeatAckMessage
|
||||
| SystemInfoRequestMessage
|
||||
| ToolCallRequestMessage;
|
||||
|
||||
// ─── Client Types ───
|
||||
|
||||
export type ConnectionStatus =
|
||||
| 'authenticating'
|
||||
| 'connected'
|
||||
| 'connecting'
|
||||
| 'disconnected'
|
||||
| 'reconnecting';
|
||||
|
||||
export interface GatewayClientEvents {
|
||||
auth_expired: () => void;
|
||||
auth_failed: (reason: string) => void;
|
||||
connected: () => void;
|
||||
disconnected: () => void;
|
||||
error: (error: Error) => void;
|
||||
heartbeat_ack: () => void;
|
||||
reconnecting: (delay: number) => void;
|
||||
status_changed: (status: ConnectionStatus) => void;
|
||||
system_info_request: (request: SystemInfoRequestMessage) => void;
|
||||
tool_call_request: (request: ToolCallRequestMessage) => void;
|
||||
}
|
||||
4
packages/device-gateway-client/tsconfig.json
Normal file
4
packages/device-gateway-client/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/"]
|
||||
}
|
||||
11
packages/device-gateway-client/vitest.config.mts
Normal file
11
packages/device-gateway-client/vitest.config.mts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
all: false,
|
||||
reporter: ['text', 'json', 'lcov', 'text-summary'],
|
||||
},
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -90,6 +90,7 @@ export default defineConfig({
|
||||
'**/public/**',
|
||||
'**/apps/desktop/**',
|
||||
'**/apps/mobile/**',
|
||||
'**/apps/cli/**',
|
||||
'**/packages/**',
|
||||
'**/e2e/**',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user