feat: add doc command in cli (#12752)

* add doc cli

* add doc cli

* add document command
This commit is contained in:
Arvin Xu
2026-03-07 13:48:02 +08:00
committed by GitHub
parent 42ed155944
commit 2822b984f4
15 changed files with 1558 additions and 122 deletions

14
apps/cli/.npmrc Normal file
View File

@@ -0,0 +1,14 @@
lockfile=false
ignore-workspace-root-check=true
public-hoist-pattern[]=*@umijs/lint*
public-hoist-pattern[]=*unicorn*
public-hoist-pattern[]=*changelog*
public-hoist-pattern[]=*commitlint*
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*postcss*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=*remark*
public-hoist-pattern[]=*semantic-release*
public-hoist-pattern[]=*stylelint*

View File

@@ -1,25 +1,40 @@
{
"name": "@lobehub/cli",
"version": "0.0.1-canary.1",
"private": true,
"version": "0.0.1-canary.3",
"type": "module",
"bin": {
"lh": "./src/index.ts"
"lh": "./dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "npx tsup",
"dev": "bun src/index.ts",
"prepublishOnly": "npm run build",
"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:*",
"@trpc/client": "^11.8.1",
"commander": "^13.1.0",
"diff": "^7.0.0",
"fast-glob": "^3.3.3",
"picocolors": "^1.1.1"
"picocolors": "^1.1.1",
"superjson": "^2.2.6",
"ws": "^8.18.1"
},
"devDependencies": {
"@lobechat/device-gateway-client": "workspace:*",
"@types/diff": "^6.0.0",
"@types/node": "^22.13.5",
"@types/ws": "^8.18.1",
"tsup": "^8.4.0",
"typescript": "^5.9.3"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
}
}

View File

@@ -0,0 +1,3 @@
packages:
- '../../packages/device-gateway-client'
- '.'

View File

@@ -0,0 +1,37 @@
import { createTRPCClient, httpLink } from '@trpc/client';
import superjson from 'superjson';
import type { LambdaRouter } from '@/server/routers/lambda';
import { getValidToken } from '../auth/refresh';
import { log } from '../utils/logger';
export type TrpcClient = ReturnType<typeof createTRPCClient<LambdaRouter>>;
let _client: TrpcClient | undefined;
export async function getTrpcClient(): Promise<TrpcClient> {
if (_client) return _client;
const result = await getValidToken();
if (!result) {
log.error("No authentication found. Run 'lh login' first.");
process.exit(1);
}
const { serverUrl, accessToken } = result.credentials;
_client = createTRPCClient<LambdaRouter>({
links: [
httpLink({
headers: {
'Oidc-Auth': accessToken,
},
transformer: superjson,
url: `${serverUrl.replace(/\/$/, '')}/trpc/lambda`,
}),
],
});
return _client;
}

View File

@@ -21,6 +21,30 @@ vi.mock('../tools/shell', () => ({
cleanupAllProcesses: vi.fn(),
}));
let mockRunningPid: number | null = null;
let mockSpawnedPid = 0;
let mockStatus: any = null;
vi.mock('../daemon/manager', () => ({
appendLog: vi.fn(),
getLogPath: vi.fn().mockReturnValue('/tmp/test-daemon.log'),
getRunningDaemonPid: vi.fn().mockImplementation(() => mockRunningPid),
readStatus: vi.fn().mockImplementation(() => mockStatus),
removePid: vi.fn(),
removeStatus: vi.fn(),
spawnDaemon: vi.fn().mockImplementation(() => {
mockSpawnedPid = 99999;
return mockSpawnedPid;
}),
stopDaemon: vi.fn().mockImplementation(() => {
if (mockRunningPid !== null) {
mockRunningPid = null;
return true;
}
return false;
}),
writeStatus: vi.fn(),
}));
vi.mock('../tools', () => ({
executeToolCall: vi.fn().mockResolvedValue({
content: 'tool result',
@@ -60,6 +84,8 @@ vi.mock('@lobechat/device-gateway-client', () => ({
// eslint-disable-next-line import-x/first
import { resolveToken } from '../auth/resolveToken';
// eslint-disable-next-line import-x/first
import { spawnDaemon, stopDaemon } from '../daemon/manager';
// eslint-disable-next-line import-x/first
import { executeToolCall } from '../tools';
// eslint-disable-next-line import-x/first
import { cleanupAllProcesses } from '../tools/shell';
@@ -73,6 +99,9 @@ describe('connect command', () => {
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
mockRunningPid = null;
mockSpawnedPid = 0;
mockStatus = null;
});
afterEach(() => {
@@ -148,7 +177,7 @@ describe('connect command', () => {
await clientEventHandlers['auth_expired']?.();
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(cleanupAllProcesses).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
@@ -169,21 +198,6 @@ describe('connect command', () => {
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;
@@ -251,4 +265,90 @@ describe('connect command', () => {
expect(sysInfo.videosPath).toContain('Videos');
}
});
describe('--daemon flag', () => {
it('should spawn daemon and exit', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', '--daemon']);
expect(spawnDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Daemon started'));
});
it('should refuse if daemon already running', async () => {
mockRunningPid = 12345;
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', '--daemon']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('already running'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('connect stop', () => {
it('should stop running daemon', async () => {
mockRunningPid = 12345;
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', 'stop']);
expect(stopDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped'));
});
it('should warn if no daemon is running', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', 'stop']);
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('No daemon'));
});
});
describe('connect status', () => {
it('should show no daemon running', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', 'status']);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('No daemon'));
});
it('should show daemon status', async () => {
mockRunningPid = 12345;
mockStatus = {
connectionStatus: 'connected',
gatewayUrl: 'https://gateway.test.com',
pid: 12345,
startedAt: new Date(Date.now() - 3600_000).toISOString(),
};
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', 'status']);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Daemon Status'));
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('12345'));
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('connected'));
});
});
describe('connect restart', () => {
it('should stop and start daemon', async () => {
mockRunningPid = 12345;
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', 'restart']);
expect(stopDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Stopped existing'));
expect(spawnDaemon).toHaveBeenCalled();
});
it('should start daemon even if none was running', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', 'restart']);
expect(spawnDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Daemon started'));
});
});
});

View File

@@ -1,3 +1,4 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -10,132 +11,336 @@ import { GatewayClient } from '@lobechat/device-gateway-client';
import type { Command } from 'commander';
import { resolveToken } from '../auth/resolveToken';
import {
appendLog,
getLogPath,
getRunningDaemonPid,
readStatus,
removePid,
removeStatus,
spawnDaemon,
stopDaemon,
writeStatus,
} from '../daemon/manager';
import { executeToolCall } from '../tools';
import { cleanupAllProcesses } from '../tools/shell';
import { log, setVerbose } from '../utils/logger';
interface ConnectOptions {
daemon?: boolean;
daemonChild?: boolean;
deviceId?: string;
gateway?: string;
serviceToken?: string;
token?: string;
userId?: string;
verbose?: boolean;
}
export function registerConnectCommand(program: Command) {
program
const connectCmd = 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')
.option('-d, --daemon', 'Run as a background daemon process')
.option('--daemon-child', 'Internal: runs as the daemon child process')
.action(async (options: ConnectOptions) => {
if (options.verbose) setVerbose(true);
const auth = await resolveToken(options);
// --daemon: spawn detached child and exit
if (options.daemon) {
return handleDaemonStart(options);
}
const client = new GatewayClient({
deviceId: options.deviceId,
gatewayUrl: options.gateway,
logger: log,
token: auth.token,
userId: auth.userId,
});
// --daemon-child: running inside daemon, redirect logging
const isDaemonChild = options.daemonChild || process.env.LOBEHUB_DAEMON === '1';
// 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();
await runConnect(options, isDaemonChild);
});
// Subcommands
connectCmd
.command('stop')
.description('Stop the background daemon process')
.action(() => {
const stopped = stopDaemon();
if (stopped) {
log.info('Daemon stopped.');
} else {
log.warn('No daemon is running.');
}
});
connectCmd
.command('status')
.description('Show background daemon status')
.action(() => {
const pid = getRunningDaemonPid();
if (pid === null) {
log.info('No daemon is running.');
return;
}
const status = readStatus();
log.info('─── Daemon Status ───');
log.info(` PID : ${pid}`);
if (status) {
log.info(` Started at : ${status.startedAt}`);
log.info(` Connection : ${status.connectionStatus}`);
log.info(` Gateway : ${status.gatewayUrl}`);
const uptime = formatUptime(new Date(status.startedAt));
log.info(` Uptime : ${uptime}`);
}
log.info('─────────────────────');
});
connectCmd
.command('logs')
.description('Tail the daemon log file')
.option('-n, --lines <count>', 'Number of lines to show', '50')
.option('-f, --follow', 'Follow log output')
.action(async (opts: { follow?: boolean; lines?: string }) => {
const logPath = getLogPath();
if (!fs.existsSync(logPath)) {
log.warn('No log file found. Start the daemon first.');
return;
}
const lines = opts.lines || '50';
const args = [`-n`, lines];
if (opts.follow) args.push('-f');
// Use tail directly — this hands control to the child process
try {
const { execFileSync } = await import('node:child_process');
execFileSync('tail', [...args, logPath], { stdio: 'inherit' });
} catch {
// tail -f exits via SIGINT, which throws — that's fine
}
});
connectCmd
.command('restart')
.description('Restart the background daemon process')
.option('--token <jwt>', 'JWT access token')
.option('--gateway <url>', 'Gateway URL', 'https://device-gateway.lobehub.com')
.option('--device-id <id>', 'Device ID')
.option('-v, --verbose', 'Enable verbose logging')
.action((options: ConnectOptions) => {
const wasStopped = stopDaemon();
if (wasStopped) {
log.info('Stopped existing daemon.');
}
handleDaemonStart({ ...options, daemon: true });
});
}
// --- Internal helpers ---
function handleDaemonStart(options: ConnectOptions) {
const existingPid = getRunningDaemonPid();
if (existingPid !== null) {
log.error(`Daemon is already running (PID ${existingPid}).`);
log.error("Use 'lh connect stop' to stop it, or 'lh connect restart' to restart.");
process.exit(1);
}
// Build args to re-run with --daemon-child
const args = buildDaemonArgs(options);
const pid = spawnDaemon(args);
log.info(`Daemon started (PID ${pid}).`);
log.info(` Logs: ${getLogPath()}`);
log.info(" Run 'lh connect status' to check connection.");
log.info(" Run 'lh connect stop' to stop.");
}
function buildDaemonArgs(options: ConnectOptions): string[] {
// Find the entry script (process.argv[1])
const script = process.argv[1];
const args = [script, 'connect'];
if (options.token) args.push('--token', options.token);
if (options.gateway) args.push('--gateway', options.gateway);
if (options.deviceId) args.push('--device-id', options.deviceId);
if (options.verbose) args.push('--verbose');
return args;
}
async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
const auth = await resolveToken(options);
const gatewayUrl = options.gateway || 'https://device-gateway.lobehub.com';
const client = new GatewayClient({
deviceId: options.deviceId,
gatewayUrl,
logger: isDaemonChild ? createDaemonLogger() : log,
token: auth.token,
userId: auth.userId,
});
const info = (msg: string) => {
if (isDaemonChild) appendLog(msg);
else log.info(msg);
};
const error = (msg: string) => {
if (isDaemonChild) appendLog(`[ERROR] ${msg}`);
else log.error(msg);
};
// Print device info
info('─── LobeHub CLI ───');
info(` Device ID : ${client.currentDeviceId}`);
info(` Hostname : ${os.hostname()}`);
info(` Platform : ${process.platform}`);
info(` Gateway : ${gatewayUrl}`);
info(` Auth : jwt`);
info(` Mode : ${isDaemonChild ? 'daemon' : 'foreground'}`);
info('───────────────────');
// Update status file for daemon mode
const updateStatus = (connectionStatus: string) => {
if (isDaemonChild) {
writeStatus({
connectionStatus,
gatewayUrl,
pid: process.pid,
startedAt: startedAt.toISOString(),
});
}
};
const startedAt = new Date();
updateStatus('connecting');
// Handle system info requests
client.on('system_info_request', (request: SystemInfoRequestMessage) => {
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;
if (isDaemonChild) {
appendLog(`[TOOL] ${toolCall.apiName} (${requestId})`);
} else {
log.toolCall(toolCall.apiName, requestId, toolCall.arguments);
}
const result = await executeToolCall(toolCall.apiName, toolCall.arguments);
if (isDaemonChild) {
appendLog(`[RESULT] ${result.success ? 'OK' : 'FAIL'} (${requestId})`);
} else {
log.toolResult(requestId, result.success, result.content);
}
client.sendToolCallResponse({
requestId,
result: {
content: result.content,
error: result.error,
success: result.success,
},
});
});
client.on('connected', () => {
updateStatus('connected');
});
client.on('disconnected', () => {
updateStatus('disconnected');
});
client.on('reconnecting', () => {
updateStatus('reconnecting');
});
// Handle auth failed
client.on('auth_failed', (reason) => {
error(`Authentication failed: ${reason}`);
error("Run 'lh login' to re-authenticate.");
cleanup();
process.exit(1);
});
// Handle auth expired
client.on('auth_expired', async () => {
error('Authentication expired. Attempting to refresh...');
const refreshed = await resolveToken({});
if (refreshed) {
info('Token refreshed. Please reconnect.');
} else {
error("Could not refresh token. Run 'lh login' to re-authenticate.");
}
cleanup();
process.exit(1);
});
// Handle errors
client.on('error', (err) => {
error(`Connection error: ${err.message}`);
});
// Graceful shutdown
const cleanup = () => {
info('Shutting down...');
cleanupAllProcesses();
client.disconnect();
if (isDaemonChild) {
removeStatus();
removePid();
}
};
process.on('SIGINT', () => {
cleanup();
process.exit(0);
});
process.on('SIGTERM', () => {
cleanup();
process.exit(0);
});
// Connect
await client.connect();
}
function createDaemonLogger() {
return {
debug: (msg: string) => appendLog(`[DEBUG] ${msg}`),
error: (msg: string) => appendLog(`[ERROR] ${msg}`),
info: (msg: string) => appendLog(`[INFO] ${msg}`),
warn: (msg: string) => appendLog(`[WARN] ${msg}`),
};
}
function formatUptime(startedAt: Date): string {
const diff = Date.now() - startedAt.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`;
if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}
function collectSystemInfo(): DeviceSystemInfo {
const home = os.homedir();
const platform = process.platform;
// Platform-specific video path name
const videosDir = platform === 'linux' ? 'Videos' : 'Movies';
return {

View File

@@ -0,0 +1,342 @@
import fs from 'node:fs';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock TRPC client — use vi.hoisted so the variable is available in vi.mock factories
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
document: {
createDocument: { mutate: vi.fn() },
deleteDocument: { mutate: vi.fn() },
deleteDocuments: { mutate: vi.fn() },
getDocumentById: { query: vi.fn() },
queryDocuments: { query: vi.fn() },
updateDocument: { mutate: vi.fn() },
},
},
}));
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
getTrpcClient: vi.fn(),
}));
vi.mock('../api/client', () => ({
getTrpcClient: mockGetTrpcClient,
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
setVerbose: vi.fn(),
}));
// eslint-disable-next-line import-x/first
import { log } from '../utils/logger';
// eslint-disable-next-line import-x/first
import { registerDocCommand } from './doc';
describe('doc command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
// Reset all document mock return values
for (const method of Object.values(mockTrpcClient.document)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerDocCommand(program);
return program;
}
// ── list ──────────────────────────────────────────────
describe('list', () => {
it('should display documents in table format', async () => {
mockTrpcClient.document.queryDocuments.query.mockResolvedValue([
{
fileType: 'md',
id: 'doc1',
title: 'Meeting Notes',
updatedAt: new Date().toISOString(),
},
{ fileType: 'md', id: 'doc2', title: 'API Design', updatedAt: new Date().toISOString() },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'list']);
expect(mockTrpcClient.document.queryDocuments.query).toHaveBeenCalledWith({
fileTypes: undefined,
pageSize: 30,
});
// Header + 2 rows
expect(consoleSpy).toHaveBeenCalledTimes(3);
expect(consoleSpy.mock.calls[0][0]).toContain('ID');
expect(consoleSpy.mock.calls[0][0]).toContain('TITLE');
});
it('should output JSON when --json flag is used', async () => {
const docs = [{ fileType: 'md', id: 'doc1', title: 'Test' }];
mockTrpcClient.document.queryDocuments.query.mockResolvedValue(docs);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'list', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(docs, null, 2));
});
it('should output JSON with selected fields', async () => {
const docs = [{ fileType: 'md', id: 'doc1', title: 'Test' }];
mockTrpcClient.document.queryDocuments.query.mockResolvedValue(docs);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'list', '--json', 'id,title']);
const output = JSON.parse(consoleSpy.mock.calls[0][0]);
expect(output).toEqual([{ id: 'doc1', title: 'Test' }]);
});
it('should filter by file type', async () => {
mockTrpcClient.document.queryDocuments.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'list', '--file-type', 'md']);
expect(mockTrpcClient.document.queryDocuments.query).toHaveBeenCalledWith({
fileTypes: ['md'],
pageSize: 30,
});
});
it('should show message when no documents found', async () => {
mockTrpcClient.document.queryDocuments.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'list']);
expect(consoleSpy).toHaveBeenCalledWith('No documents found.');
});
it('should respect --limit flag', async () => {
mockTrpcClient.document.queryDocuments.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'list', '-L', '10']);
expect(mockTrpcClient.document.queryDocuments.query).toHaveBeenCalledWith({
fileTypes: undefined,
pageSize: 10,
});
});
});
// ── view ──────────────────────────────────────────────
describe('view', () => {
it('should display document content', async () => {
mockTrpcClient.document.getDocumentById.query.mockResolvedValue({
content: '# Hello World',
fileType: 'md',
id: 'doc1',
title: 'Test Doc',
updatedAt: new Date().toISOString(),
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'view', 'doc1']);
expect(mockTrpcClient.document.getDocumentById.query).toHaveBeenCalledWith({ id: 'doc1' });
// Title, meta, blank line, content = 4 calls
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Test Doc'));
expect(consoleSpy).toHaveBeenCalledWith('# Hello World');
});
it('should output JSON when --json flag is used', async () => {
const doc = { content: 'test', id: 'doc1', title: 'Test' };
mockTrpcClient.document.getDocumentById.query.mockResolvedValue(doc);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'view', 'doc1', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(doc, null, 2));
});
it('should exit with error when document not found', async () => {
mockTrpcClient.document.getDocumentById.query.mockResolvedValue(null);
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'view', 'nonexistent']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
// ── create ────────────────────────────────────────────
describe('create', () => {
it('should create a document with title and body', async () => {
mockTrpcClient.document.createDocument.mutate.mockResolvedValue({ id: 'new-doc' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'doc',
'create',
'--title',
'My Doc',
'--body',
'Hello',
]);
expect(mockTrpcClient.document.createDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Hello',
title: 'My Doc',
}),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('new-doc'));
});
it('should read content from file with --body-file', async () => {
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue('file content');
mockTrpcClient.document.createDocument.mutate.mockResolvedValue({ id: 'new-doc' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'doc',
'create',
'--title',
'From File',
'--body-file',
'./test.md',
]);
expect(mockTrpcClient.document.createDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({
content: 'file content',
title: 'From File',
}),
);
vi.restoreAllMocks();
});
it('should support --parent and --slug flags', async () => {
mockTrpcClient.document.createDocument.mutate.mockResolvedValue({ id: 'new-doc' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'doc',
'create',
'--title',
'Child Doc',
'--parent',
'parent-id',
'--slug',
'child-doc',
]);
expect(mockTrpcClient.document.createDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({
parentId: 'parent-id',
slug: 'child-doc',
title: 'Child Doc',
}),
);
});
});
// ── edit ──────────────────────────────────────────────
describe('edit', () => {
it('should update document title', async () => {
mockTrpcClient.document.updateDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'edit', 'doc1', '--title', 'New Title']);
expect(mockTrpcClient.document.updateDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({
id: 'doc1',
title: 'New Title',
}),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Updated'));
});
it('should update document body', async () => {
mockTrpcClient.document.updateDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'edit', 'doc1', '--body', 'new content']);
expect(mockTrpcClient.document.updateDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({
content: 'new content',
id: 'doc1',
}),
);
});
it('should exit with error when no changes specified', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'edit', 'doc1']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes specified'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
// ── delete ────────────────────────────────────────────
describe('delete', () => {
it('should delete a single document with --yes', async () => {
mockTrpcClient.document.deleteDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'delete', 'doc1', '--yes']);
expect(mockTrpcClient.document.deleteDocument.mutate).toHaveBeenCalledWith({ id: 'doc1' });
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Deleted'));
});
it('should delete multiple documents with --yes', async () => {
mockTrpcClient.document.deleteDocuments.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'doc', 'delete', 'doc1', 'doc2', '--yes']);
expect(mockTrpcClient.document.deleteDocuments.mutate).toHaveBeenCalledWith({
ids: ['doc1', 'doc2'],
});
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Deleted 2'));
});
});
});

View File

@@ -0,0 +1,271 @@
import fs from 'node:fs';
import readline from 'node:readline';
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { log } from '../utils/logger';
// ── Helpers ────────────────────────────────────────────────
function timeAgo(date: Date | string): string {
const diff = Date.now() - new Date(date).getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return `${seconds}s ago`;
}
function truncate(str: string, len: number): string {
if (str.length <= len) return str;
return str.slice(0, len - 1) + '…';
}
function printTable(rows: string[][], header: string[]) {
const allRows = [header, ...rows];
const colWidths = header.map((_, i) => Math.max(...allRows.map((r) => (r[i] || '').length)));
// Header
const headerLine = header.map((h, i) => h.padEnd(colWidths[i])).join(' ');
console.log(pc.bold(headerLine));
// Rows
for (const row of rows) {
const line = row.map((cell, i) => (cell || '').padEnd(colWidths[i])).join(' ');
console.log(line);
}
}
function pickFields(obj: Record<string, any>, fields: string[]): Record<string, any> {
const result: Record<string, any> = {};
for (const f of fields) {
if (f in obj) result[f] = obj[f];
}
return result;
}
function outputJson(data: unknown, fields?: string) {
if (fields) {
const fieldList = fields.split(',').map((f) => f.trim());
if (Array.isArray(data)) {
console.log(
JSON.stringify(
data.map((item) => pickFields(item, fieldList)),
null,
2,
),
);
} else if (data && typeof data === 'object') {
console.log(JSON.stringify(pickFields(data as Record<string, any>, fieldList), null, 2));
}
} else {
console.log(JSON.stringify(data, null, 2));
}
}
function readBodyContent(options: { body?: string; bodyFile?: string }): string | undefined {
if (options.bodyFile) {
if (!fs.existsSync(options.bodyFile)) {
log.error(`File not found: ${options.bodyFile}`);
process.exit(1);
}
return fs.readFileSync(options.bodyFile, 'utf8');
}
return options.body;
}
async function confirm(message: string): Promise<boolean> {
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
return new Promise((resolve) => {
rl.question(`${message} (y/N) `, (answer) => {
rl.close();
resolve(answer.toLowerCase() === 'y');
});
});
}
// ── Command Registration ───────────────────────────────────
export function registerDocCommand(program: Command) {
const doc = program.command('doc').description('Manage documents');
// ── list ──────────────────────────────────────────────
doc
.command('list')
.description('List documents')
.option('-L, --limit <n>', 'Maximum number of items to fetch', '30')
.option('--file-type <type>', 'Filter by file type')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { fileType?: string; json?: string | boolean; limit?: string }) => {
const client = await getTrpcClient();
const pageSize = Number.parseInt(options.limit || '30', 10);
const query: { fileTypes?: string[]; pageSize: number } = { pageSize };
if (options.fileType) query.fileTypes = [options.fileType];
const result = await client.document.queryDocuments.query(query);
const docs = Array.isArray(result) ? result : ((result as any).items ?? []);
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(docs, fields);
return;
}
if (docs.length === 0) {
console.log('No documents found.');
return;
}
const rows = docs.map((d: any) => [
d.id,
truncate(d.title || d.filename || 'Untitled', 120),
d.fileType || '',
d.updatedAt ? timeAgo(d.updatedAt) : '',
]);
printTable(rows, ['ID', 'TITLE', 'TYPE', 'UPDATED']);
});
// ── view ──────────────────────────────────────────────
doc
.command('view <id>')
.description('View a document')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const document = await client.document.getDocumentById.query({ id });
if (!document) {
log.error(`Document not found: ${id}`);
process.exit(1);
return;
}
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(document, fields);
return;
}
// Human-readable output
console.log(pc.bold(document.title || 'Untitled'));
const meta: string[] = [];
if (document.fileType) meta.push(document.fileType);
if (document.updatedAt) meta.push(`Updated ${timeAgo(document.updatedAt)}`);
if (meta.length > 0) console.log(pc.dim(meta.join(' · ')));
console.log();
if (document.content) {
console.log(document.content);
} else {
console.log(pc.dim('(no content)'));
}
});
// ── create ────────────────────────────────────────────
doc
.command('create')
.description('Create a new document')
.requiredOption('-t, --title <title>', 'Document title')
.option('-b, --body <content>', 'Document content')
.option('-F, --body-file <path>', 'Read content from file')
.option('--parent <id>', 'Parent document or folder ID')
.option('--slug <slug>', 'Custom slug')
.action(
async (options: {
body?: string;
bodyFile?: string;
parent?: string;
slug?: string;
title: string;
}) => {
const content = readBodyContent(options);
const client = await getTrpcClient();
const result = await client.document.createDocument.mutate({
content,
editorData: JSON.stringify({ content: content || '', type: 'doc' }),
parentId: options.parent,
slug: options.slug,
title: options.title,
});
console.log(`${pc.green('✓')} Created document ${pc.bold(result.id)}`);
},
);
// ── edit ──────────────────────────────────────────────
doc
.command('edit <id>')
.description('Edit a document')
.option('-t, --title <title>', 'New title')
.option('-b, --body <content>', 'New content')
.option('-F, --body-file <path>', 'Read new content from file')
.option('--parent <id>', 'Move to parent document (empty string for root)')
.action(
async (
id: string,
options: { body?: string; bodyFile?: string; parent?: string; title?: string },
) => {
const content = readBodyContent(options);
if (!options.title && !content && options.parent === undefined) {
log.error('No changes specified. Use --title, --body, --body-file, or --parent.');
process.exit(1);
}
const client = await getTrpcClient();
const params: Record<string, any> = { id };
if (options.title) params.title = options.title;
if (content !== undefined) {
params.content = content;
params.editorData = JSON.stringify({ content, type: 'doc' });
}
if (options.parent !== undefined) {
params.parentId = options.parent || null;
}
await client.document.updateDocument.mutate(params as any);
console.log(`${pc.green('✓')} Updated document ${pc.bold(id)}`);
},
);
// ── delete ────────────────────────────────────────────
doc
.command('delete <ids...>')
.description('Delete one or more documents')
.option('--yes', 'Skip confirmation prompt')
.action(async (ids: string[], options: { yes?: boolean }) => {
if (!options.yes) {
const confirmed = await confirm(
`Are you sure you want to delete ${ids.length} document(s)?`,
);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
if (ids.length === 1) {
await client.document.deleteDocument.mutate({ id: ids[0] });
} else {
await client.document.deleteDocuments.mutate({ ids });
}
console.log(`${pc.green('✓')} Deleted ${ids.length} document(s)`);
});
}

View File

@@ -18,9 +18,10 @@ vi.mock('../utils/logger', () => ({
},
}));
// Mock child_process.exec to prevent browser opening
// Mock child_process to prevent browser opening
vi.mock('node:child_process', () => ({
exec: vi.fn((_cmd: string, cb: any) => cb?.(null)),
execFile: vi.fn((_cmd: string, _args: string[], cb: any) => cb?.(null)),
}));
describe('login command', () => {

View File

@@ -0,0 +1,236 @@
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';
const tmpDir = path.join(os.tmpdir(), 'daemon-test-' + process.pid);
const mockDir = path.join(tmpDir, '.lobehub');
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<Record<string, any>>();
return {
...actual,
default: {
...actual['default'],
homedir: () => tmpDir,
},
};
});
// eslint-disable-next-line import-x/first
import {
appendLog,
getLogPath,
getRunningDaemonPid,
isProcessAlive,
readPid,
readStatus,
removePid,
removeStatus,
rotateLogIfNeeded,
stopDaemon,
writePid,
writeStatus,
} from './manager';
describe('daemon manager', () => {
beforeEach(async () => {
await mkdir(mockDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { force: true, recursive: true });
});
describe('PID file', () => {
it('should write and read PID', () => {
writePid(12345);
expect(readPid()).toBe(12345);
});
it('should return null when no PID file', () => {
expect(readPid()).toBeNull();
});
it('should return null for invalid PID content', async () => {
await writeFile(path.join(mockDir, 'daemon.pid'), 'not-a-number');
expect(readPid()).toBeNull();
});
it('should remove PID file', () => {
writePid(12345);
removePid();
expect(readPid()).toBeNull();
});
it('should not throw when removing non-existent PID file', () => {
expect(() => removePid()).not.toThrow();
});
});
describe('isProcessAlive', () => {
it('should return true for current process', () => {
expect(isProcessAlive(process.pid)).toBe(true);
});
it('should return false for non-existent PID', () => {
expect(isProcessAlive(999999)).toBe(false);
});
});
describe('getRunningDaemonPid', () => {
it('should return null when no PID file', () => {
expect(getRunningDaemonPid()).toBeNull();
});
it('should return PID when process is alive', () => {
writePid(process.pid);
expect(getRunningDaemonPid()).toBe(process.pid);
});
it('should clean up stale PID file and return null', () => {
writePid(999999);
expect(getRunningDaemonPid()).toBeNull();
// PID file should be removed
expect(readPid()).toBeNull();
});
it('should also remove status file for stale PID', () => {
writePid(999999);
writeStatus({
connectionStatus: 'connected',
gatewayUrl: 'https://test.com',
pid: 999999,
startedAt: new Date().toISOString(),
});
getRunningDaemonPid();
expect(readStatus()).toBeNull();
});
});
describe('status file', () => {
it('should write and read status', () => {
const status = {
connectionStatus: 'connected',
gatewayUrl: 'https://gateway.test.com',
pid: 12345,
startedAt: '2026-01-01T00:00:00.000Z',
};
writeStatus(status);
expect(readStatus()).toEqual(status);
});
it('should return null when no status file', () => {
expect(readStatus()).toBeNull();
});
it('should remove status file', () => {
writeStatus({
connectionStatus: 'connected',
gatewayUrl: 'https://test.com',
pid: 1,
startedAt: '',
});
removeStatus();
expect(readStatus()).toBeNull();
});
it('should not throw when removing non-existent status file', () => {
expect(() => removeStatus()).not.toThrow();
});
});
describe('log file', () => {
it('should return correct log path', () => {
expect(getLogPath()).toBe(path.join(mockDir, 'daemon.log'));
});
it('should append log lines', () => {
appendLog('test message');
appendLog('second line');
const content = fs.readFileSync(getLogPath(), 'utf8');
expect(content).toContain('test message');
expect(content).toContain('second line');
// Should have ISO timestamps
expect(content).toMatch(/\[\d{4}-\d{2}-\d{2}T/);
});
it('should rotate log when exceeding max size', async () => {
const logPath = getLogPath();
// Write a file larger than 5MB
const bigContent = 'x'.repeat(6 * 1024 * 1024);
await writeFile(logPath, bigContent);
rotateLogIfNeeded();
// Original should be gone or rotated
expect(fs.existsSync(logPath + '.1')).toBe(true);
// New writes should go to a fresh file
expect(fs.existsSync(logPath)).toBe(false);
});
it('should not rotate when log is small', async () => {
const logPath = getLogPath();
await writeFile(logPath, 'small content');
rotateLogIfNeeded();
expect(fs.existsSync(logPath + '.1')).toBe(false);
expect(fs.readFileSync(logPath, 'utf8')).toBe('small content');
});
it('should handle rotation when no log file exists', () => {
expect(() => rotateLogIfNeeded()).not.toThrow();
});
});
describe('stopDaemon', () => {
it('should return false when no daemon is running', () => {
expect(stopDaemon()).toBe(false);
});
it('should return true and clean up when daemon is running', () => {
// Use current PID as a "running" daemon
writePid(process.pid);
writeStatus({
connectionStatus: 'connected',
gatewayUrl: 'https://test.com',
pid: process.pid,
startedAt: '',
});
// Mock process.kill to avoid actually sending SIGTERM to ourselves
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const result = stopDaemon();
expect(result).toBe(true);
expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGTERM');
expect(readPid()).toBeNull();
expect(readStatus()).toBeNull();
killSpy.mockRestore();
});
it('should handle kill error gracefully', () => {
writePid(process.pid);
let callCount = 0;
const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => {
callCount++;
if (callCount === 1) return true; // isProcessAlive check (signal 0)
throw new Error('no such process'); // actual SIGTERM
}) as any);
const result = stopDaemon();
expect(result).toBe(true);
killSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,192 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
function getLobehubDir() {
return path.join(os.homedir(), '.lobehub');
}
function getPidPath() {
return path.join(getLobehubDir(), 'daemon.pid');
}
function getStatusPath() {
return path.join(getLobehubDir(), 'daemon.status.json');
}
function getLogFilePath() {
return path.join(getLobehubDir(), 'daemon.log');
}
export interface DaemonStatus {
connectionStatus: string;
gatewayUrl: string;
pid: number;
startedAt: string;
}
function ensureDir() {
fs.mkdirSync(getLobehubDir(), { mode: 0o700, recursive: true });
}
// --- PID file ---
export function readPid(): number | null {
try {
const raw = fs.readFileSync(getPidPath(), 'utf8').trim();
const pid = Number.parseInt(raw, 10);
return Number.isNaN(pid) ? null : pid;
} catch {
return null;
}
}
export function writePid(pid: number): void {
ensureDir();
fs.writeFileSync(getPidPath(), String(pid), { mode: 0o600 });
}
export function removePid(): void {
try {
fs.unlinkSync(getPidPath());
} catch {
// ignore
}
}
/**
* Check if a process with the given PID is alive.
*/
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/**
* Get the PID of a running daemon, cleaning up stale PID files.
* Returns null if no daemon is running.
*/
export function getRunningDaemonPid(): number | null {
const pid = readPid();
if (pid === null) return null;
if (isProcessAlive(pid)) return pid;
// Stale PID file — process is dead
removePid();
removeStatus();
return null;
}
// --- Status file ---
export function writeStatus(status: DaemonStatus): void {
ensureDir();
fs.writeFileSync(getStatusPath(), JSON.stringify(status, null, 2), { mode: 0o600 });
}
export function readStatus(): DaemonStatus | null {
try {
return JSON.parse(fs.readFileSync(getStatusPath(), 'utf8')) as DaemonStatus;
} catch {
return null;
}
}
export function removeStatus(): void {
try {
fs.unlinkSync(getStatusPath());
} catch {
// ignore
}
}
// --- Log file ---
export function getLogPath(): string {
return getLogFilePath();
}
/**
* Rotate the log file if it exceeds MAX_LOG_SIZE.
*/
export function rotateLogIfNeeded(): void {
try {
const stat = fs.statSync(getLogFilePath());
if (stat.size > MAX_LOG_SIZE) {
const rotated = getLogFilePath() + '.1';
// Keep only one backup
try {
fs.unlinkSync(rotated);
} catch {
// ignore
}
fs.renameSync(getLogFilePath(), rotated);
}
} catch {
// File doesn't exist yet, nothing to rotate
}
}
/**
* Append a timestamped line to the daemon log file.
*/
export function appendLog(line: string): void {
ensureDir();
rotateLogIfNeeded();
const ts = new Date().toISOString();
fs.appendFileSync(getLogFilePath(), `[${ts}] ${line}\n`);
}
// --- Daemon spawn ---
/**
* Spawn the current script as a detached daemon process.
* The parent writes the PID file and returns immediately.
*/
export function spawnDaemon(args: string[]): number {
ensureDir();
const logFd = fs.openSync(getLogFilePath(), 'a');
// Re-run the same entry with --daemon-child (internal flag)
const child = spawn(process.execPath, [...process.execArgv, ...args, '--daemon-child'], {
detached: true,
env: { ...process.env, LOBEHUB_DAEMON: '1' },
stdio: ['ignore', logFd, logFd],
});
child.unref();
const pid = child.pid!;
writePid(pid);
fs.closeSync(logFd);
return pid;
}
/**
* Stop the running daemon process.
* Returns true if a process was killed, false if none was running.
*/
export function stopDaemon(): boolean {
const pid = getRunningDaemonPid();
if (pid === null) return false;
try {
process.kill(pid, 'SIGTERM');
} catch {
// Process may have exited between check and kill
}
removePid();
removeStatus();
return true;
}

View File

@@ -1,8 +1,7 @@
#!/usr/bin/env bun
import { Command } from 'commander';
import { registerConnectCommand } from './commands/connect';
import { registerDocCommand } from './commands/doc';
import { registerLoginCommand } from './commands/login';
import { registerLogoutCommand } from './commands/logout';
import { registerStatusCommand } from './commands/status';
@@ -18,5 +17,6 @@ registerLoginCommand(program);
registerLogoutCommand(program);
registerConnectCommand(program);
registerStatusCommand(program);
registerDocCommand(program);
program.parse();

View File

@@ -13,8 +13,10 @@
"resolveJsonModule": true,
"isolatedModules": true,
"paths": {
"@lobechat/device-gateway-client": ["../../packages/device-gateway-client/src"]
"@lobechat/device-gateway-client": ["../../packages/device-gateway-client/src"],
"@/*": ["../../src/*"]
}
},
"exclude": ["**/*.test.ts"],
"include": ["src"]
}

11
apps/cli/tsup.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
banner: { js: '#!/usr/bin/env node' },
clean: true,
entry: ['src/index.ts'],
format: ['esm'],
noExternal: ['@lobechat/device-gateway-client', '@trpc/client', 'superjson'],
platform: 'node',
target: 'node18',
});

View File

@@ -140,4 +140,11 @@ export default eslint(
'no-console': 0,
},
},
// lobehub-cli - console output is the primary interface
{
files: ['apps/cli/**/*'],
rules: {
'no-console': 0,
},
},
);