mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ feat: add doc command in cli (#12752)
* add doc cli * add doc cli * add document command
This commit is contained in:
14
apps/cli/.npmrc
Normal file
14
apps/cli/.npmrc
Normal 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*
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
3
apps/cli/pnpm-workspace.yaml
Normal file
3
apps/cli/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- '../../packages/device-gateway-client'
|
||||
- '.'
|
||||
37
apps/cli/src/api/client.ts
Normal file
37
apps/cli/src/api/client.ts
Normal 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;
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
342
apps/cli/src/commands/doc.test.ts
Normal file
342
apps/cli/src/commands/doc.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
271
apps/cli/src/commands/doc.ts
Normal file
271
apps/cli/src/commands/doc.ts
Normal 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)`);
|
||||
});
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
236
apps/cli/src/daemon/manager.test.ts
Normal file
236
apps/cli/src/daemon/manager.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
192
apps/cli/src/daemon/manager.ts
Normal file
192
apps/cli/src/daemon/manager.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
11
apps/cli/tsup.config.ts
Normal 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',
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user