mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
♻️ refactor(cli): extract shared @lobechat/local-file-shell package (#12865)
* ♻️ refactor(cli): extract shared @lobechat/local-file-shell package Extract common file and shell operations from Desktop and CLI into a shared package to eliminate ~1500 lines of duplicated code. CLI now uses @lobechat/file-loaders for rich format support (PDF, DOCX, etc.). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * update * update commands * update version * update deps * refactor version issue * ✨ feat(local-file-shell): add cwd support, move/rename ops, improve logging - Add missing `cwd` parameter to `runCommand` (align with Desktop) - Add `moveLocalFiles` with batch support and detailed error handling - Add `renameLocalFile` with path validation and traversal prevention - Add error logging in shell runner's error/completion handlers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * support update model and provider in cli * fix desktop build * fix * 🐛 fix: pin fast-xml-parser to 5.4.2 in bun overrides Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.1-canary.5",
|
||||
"version": "0.0.1-canary.8",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js"
|
||||
@@ -21,7 +21,8 @@
|
||||
"dependencies": {
|
||||
"@trpc/client": "^11.8.1",
|
||||
"commander": "^13.1.0",
|
||||
"diff": "^7.0.0",
|
||||
"debug": "^4.4.0",
|
||||
"diff": "^8.0.3",
|
||||
"fast-glob": "^3.3.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"superjson": "^2.2.6",
|
||||
@@ -29,7 +30,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@types/diff": "^6.0.0",
|
||||
"@lobechat/local-file-shell": "workspace:*",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsup": "^8.4.0",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
packages:
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/local-file-shell'
|
||||
- '../../packages/file-loaders'
|
||||
- '.'
|
||||
|
||||
@@ -10,6 +10,7 @@ const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
createAgent: { mutate: vi.fn() },
|
||||
duplicateAgent: { mutate: vi.fn() },
|
||||
getAgentConfigById: { query: vi.fn() },
|
||||
getBuiltinAgent: { query: vi.fn() },
|
||||
queryAgents: { query: vi.fn() },
|
||||
removeAgent: { mutate: vi.fn() },
|
||||
updateAgentConfig: { mutate: vi.fn() },
|
||||
@@ -136,6 +137,27 @@ describe('agent command', () => {
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should support --slug option', async () => {
|
||||
mockTrpcClient.agent.getBuiltinAgent.query.mockResolvedValue({
|
||||
id: 'resolved-id',
|
||||
model: 'gpt-4',
|
||||
title: 'Inbox Agent',
|
||||
});
|
||||
mockTrpcClient.agent.getAgentConfigById.query.mockResolvedValue({
|
||||
id: 'resolved-id',
|
||||
model: 'gpt-4',
|
||||
title: 'Inbox Agent',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'agent', 'view', '--slug', 'inbox']);
|
||||
|
||||
expect(mockTrpcClient.agent.getBuiltinAgent.query).toHaveBeenCalledWith({ slug: 'inbox' });
|
||||
expect(mockTrpcClient.agent.getAgentConfigById.query).toHaveBeenCalledWith({
|
||||
agentId: 'resolved-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
@@ -186,6 +208,32 @@ describe('agent command', () => {
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('No changes'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should support --slug option', async () => {
|
||||
mockTrpcClient.agent.getBuiltinAgent.query.mockResolvedValue({
|
||||
id: 'resolved-id',
|
||||
title: 'Inbox Agent',
|
||||
});
|
||||
mockTrpcClient.agent.updateAgentConfig.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'edit',
|
||||
'--slug',
|
||||
'inbox',
|
||||
'--model',
|
||||
'gemini-3-pro',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.getBuiltinAgent.query).toHaveBeenCalledWith({ slug: 'inbox' });
|
||||
expect(mockTrpcClient.agent.updateAgentConfig.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'resolved-id',
|
||||
value: { model: 'gemini-3-pro' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
|
||||
@@ -9,6 +9,30 @@ import { replayAgentEvents, streamAgentEvents } from '../utils/agentStream';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Resolve an agent identifier (agentId or slug) to a concrete agentId.
|
||||
* When a slug is provided, uses getBuiltinAgent to look up the agent.
|
||||
*/
|
||||
async function resolveAgentId(
|
||||
client: any,
|
||||
opts: { agentId?: string; slug?: string },
|
||||
): Promise<string> {
|
||||
if (opts.agentId) return opts.agentId;
|
||||
|
||||
if (opts.slug) {
|
||||
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
|
||||
if (!agent) {
|
||||
log.error(`Agent not found for slug: ${opts.slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return (agent as any).id || (agent as any).agentId;
|
||||
}
|
||||
|
||||
log.error('Either <agentId> or --slug is required.');
|
||||
process.exit(1);
|
||||
return ''; // unreachable
|
||||
}
|
||||
|
||||
export function registerAgentCommand(program: Command) {
|
||||
const agent = program.command('agent').description('Manage agents');
|
||||
|
||||
@@ -54,39 +78,46 @@ export function registerAgentCommand(program: Command) {
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
agent
|
||||
.command('view <agentId>')
|
||||
.command('view [agentId]')
|
||||
.description('View agent configuration')
|
||||
.option('-s, --slug <slug>', 'Agent slug (e.g. inbox)')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (agentId: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agent.getAgentConfigById.query({ agentId });
|
||||
.action(
|
||||
async (
|
||||
agentIdArg: string | undefined,
|
||||
options: { json?: string | boolean; slug?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const agentId = await resolveAgentId(client, { agentId: agentIdArg, slug: options.slug });
|
||||
const result = await client.agent.getAgentConfigById.query({ agentId });
|
||||
|
||||
if (!result) {
|
||||
log.error(`Agent not found: ${agentId}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
if (!result) {
|
||||
log.error(`Agent not found: ${agentId}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(pc.bold(r.title || r.meta?.title || 'Untitled'));
|
||||
const meta: string[] = [];
|
||||
if (r.description || r.meta?.description) meta.push(r.description || r.meta.description);
|
||||
if (r.model) meta.push(`Model: ${r.model}`);
|
||||
if (r.provider) meta.push(`Provider: ${r.provider}`);
|
||||
if (meta.length > 0) console.log(pc.dim(meta.join(' · ')));
|
||||
const r = result as any;
|
||||
console.log(pc.bold(r.title || r.meta?.title || 'Untitled'));
|
||||
const meta: string[] = [];
|
||||
if (r.description || r.meta?.description) meta.push(r.description || r.meta.description);
|
||||
if (r.model) meta.push(`Model: ${r.model}`);
|
||||
if (r.provider) meta.push(`Provider: ${r.provider}`);
|
||||
if (meta.length > 0) console.log(pc.dim(meta.join(' · ')));
|
||||
|
||||
if (r.systemRole) {
|
||||
console.log();
|
||||
console.log(pc.bold('System Role:'));
|
||||
console.log(r.systemRole);
|
||||
}
|
||||
});
|
||||
if (r.systemRole) {
|
||||
console.log();
|
||||
console.log(pc.bold('System Role:'));
|
||||
console.log(r.systemRole);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
@@ -130,8 +161,9 @@ export function registerAgentCommand(program: Command) {
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
|
||||
agent
|
||||
.command('edit <agentId>')
|
||||
.command('edit [agentId]')
|
||||
.description('Update agent configuration')
|
||||
.option('--slug <slug>', 'Agent slug (e.g. inbox)')
|
||||
.option('-t, --title <title>', 'New title')
|
||||
.option('-d, --description <desc>', 'New description')
|
||||
.option('-m, --model <model>', 'New model ID')
|
||||
@@ -139,11 +171,12 @@ export function registerAgentCommand(program: Command) {
|
||||
.option('-s, --system-role <role>', 'New system role prompt')
|
||||
.action(
|
||||
async (
|
||||
agentId: string,
|
||||
agentIdArg: string | undefined,
|
||||
options: {
|
||||
description?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
slug?: string;
|
||||
systemRole?: string;
|
||||
title?: string;
|
||||
},
|
||||
@@ -163,6 +196,7 @@ export function registerAgentCommand(program: Command) {
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const agentId = await resolveAgentId(client, { agentId: agentIdArg, slug: options.slug });
|
||||
await client.agent.updateAgentConfig.mutate({ agentId, value });
|
||||
console.log(`${pc.green('✓')} Updated agent ${pc.bold(agentId)}`);
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { registerConfigCommand } from './config';
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
usage: {
|
||||
findAndGroupByDateRange: { query: vi.fn() },
|
||||
findAndGroupByDay: { query: vi.fn() },
|
||||
findByMonth: { query: vi.fn() },
|
||||
},
|
||||
@@ -34,6 +35,8 @@ describe('config command', () => {
|
||||
mockTrpcClient.user.getUserState.query.mockReset();
|
||||
mockTrpcClient.usage.findByMonth.query.mockReset();
|
||||
mockTrpcClient.usage.findAndGroupByDay.query.mockReset();
|
||||
mockTrpcClient.usage.findAndGroupByDateRange.query.mockReset();
|
||||
mockTrpcClient.usage.findAndGroupByDateRange.query.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -75,36 +78,34 @@ describe('config command', () => {
|
||||
});
|
||||
|
||||
describe('usage', () => {
|
||||
it('should display monthly usage', async () => {
|
||||
mockTrpcClient.usage.findByMonth.query.mockResolvedValue({ totalTokens: 1000 });
|
||||
it('should display usage table', async () => {
|
||||
mockTrpcClient.usage.findAndGroupByDay.query.mockResolvedValue([
|
||||
{
|
||||
day: '2024-01-15',
|
||||
records: [{ model: 'claude-opus-4-6', totalInputTokens: 500, totalOutputTokens: 500 }],
|
||||
totalRequests: 1,
|
||||
totalSpend: 0.5,
|
||||
totalTokens: 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'usage']);
|
||||
|
||||
expect(mockTrpcClient.usage.findByMonth.query).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display daily usage', async () => {
|
||||
mockTrpcClient.usage.findAndGroupByDay.query.mockResolvedValue([
|
||||
{ date: '2024-01-01', totalTokens: 100 },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'usage', '--daily']);
|
||||
|
||||
expect(mockTrpcClient.usage.findAndGroupByDay.query).toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('2024-01-15'));
|
||||
});
|
||||
|
||||
it('should pass month param', async () => {
|
||||
mockTrpcClient.usage.findByMonth.query.mockResolvedValue({});
|
||||
mockTrpcClient.usage.findAndGroupByDay.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'usage', '--month', '2024-01']);
|
||||
|
||||
expect(mockTrpcClient.usage.findByMonth.query).toHaveBeenCalledWith({ mo: '2024-01' });
|
||||
expect(mockTrpcClient.usage.findAndGroupByDay.query).toHaveBeenCalledWith({ mo: '2024-01' });
|
||||
});
|
||||
|
||||
it('should output JSON', async () => {
|
||||
it('should output JSON with --json flag', async () => {
|
||||
const data = { totalTokens: 1000 };
|
||||
mockTrpcClient.usage.findByMonth.query.mockResolvedValue(data);
|
||||
|
||||
@@ -113,5 +114,16 @@ describe('config command', () => {
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2));
|
||||
});
|
||||
|
||||
it('should output JSON daily with --json --daily', async () => {
|
||||
const data = [{ day: '2024-01-01', totalTokens: 100 }];
|
||||
mockTrpcClient.usage.findAndGroupByDay.query.mockResolvedValue(data);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'usage', '--json', '--daily']);
|
||||
|
||||
expect(mockTrpcClient.usage.findAndGroupByDay.query).toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,14 @@ import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { outputJson } from '../utils/format';
|
||||
import {
|
||||
type BoxTableRow,
|
||||
formatCost,
|
||||
formatNumber,
|
||||
outputJson,
|
||||
printBoxTable,
|
||||
printCalendarHeatmap,
|
||||
} from '../utils/format';
|
||||
|
||||
export function registerConfigCommand(program: Command) {
|
||||
// ── whoami ────────────────────────────────────────────
|
||||
@@ -44,35 +51,146 @@ export function registerConfigCommand(program: Command) {
|
||||
const input: { mo?: string } = {};
|
||||
if (options.month) input.mo = options.month;
|
||||
|
||||
let result: any;
|
||||
if (options.daily) {
|
||||
result = await client.usage.findAndGroupByDay.query(input);
|
||||
} else {
|
||||
result = await client.usage.findByMonth.query(input);
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
let jsonResult: any;
|
||||
if (options.daily) {
|
||||
jsonResult = await client.usage.findAndGroupByDay.query(input);
|
||||
} else {
|
||||
jsonResult = await client.usage.findByMonth.query(input);
|
||||
}
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
outputJson(jsonResult, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always fetch daily-grouped data for table display
|
||||
const result: any = await client.usage.findAndGroupByDay.query(input);
|
||||
|
||||
if (!result) {
|
||||
console.log('No usage data available.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.daily && Array.isArray(result)) {
|
||||
console.log(pc.bold('Daily Usage'));
|
||||
for (const entry of result) {
|
||||
const e = entry as any;
|
||||
const day = e.date || e.day || '';
|
||||
const tokens = e.totalTokens || e.tokens || 0;
|
||||
console.log(` ${day}: ${tokens} tokens`);
|
||||
}
|
||||
} else {
|
||||
console.log(pc.bold('Monthly Usage'));
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
// Normalize result to an array of daily logs
|
||||
const logs: any[] = Array.isArray(result) ? result : [result];
|
||||
|
||||
// Filter out days with zero activity for cleaner output
|
||||
const activeLogs = logs.filter(
|
||||
(l: any) => (l.totalTokens || 0) > 0 || (l.totalRequests || 0) > 0,
|
||||
);
|
||||
|
||||
if (activeLogs.length === 0) {
|
||||
console.log('No usage data available.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build table columns
|
||||
const columns = [
|
||||
{ align: 'left' as const, header: 'Date', key: 'date' },
|
||||
{ align: 'left' as const, header: 'Models', key: 'models' },
|
||||
{ align: 'right' as const, header: 'Input', key: 'input' },
|
||||
{ align: 'right' as const, header: 'Output', key: 'output' },
|
||||
{ align: 'right' as const, header: ['Total', 'Tokens'], key: 'total' },
|
||||
{ align: 'right' as const, header: 'Requests', key: 'requests' },
|
||||
{ align: 'right' as const, header: ['Cost', '(USD)'], key: 'cost' },
|
||||
];
|
||||
|
||||
// Totals
|
||||
let sumInput = 0;
|
||||
let sumOutput = 0;
|
||||
let sumTotal = 0;
|
||||
let sumRequests = 0;
|
||||
let sumCost = 0;
|
||||
|
||||
const rows: BoxTableRow[] = activeLogs.map((log: any) => {
|
||||
const records: any[] = log.records || [];
|
||||
|
||||
// Aggregate tokens
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
for (const r of records) {
|
||||
inputTokens += r.totalInputTokens || 0;
|
||||
outputTokens += r.totalOutputTokens || 0;
|
||||
}
|
||||
const totalTokens = log.totalTokens || inputTokens + outputTokens;
|
||||
const cost = log.totalSpend || 0;
|
||||
const requests = log.totalRequests || 0;
|
||||
|
||||
sumInput += inputTokens;
|
||||
sumOutput += outputTokens;
|
||||
sumTotal += totalTokens;
|
||||
sumRequests += requests;
|
||||
sumCost += cost;
|
||||
|
||||
// Unique models
|
||||
const modelSet = new Set<string>();
|
||||
for (const r of records) {
|
||||
if (r.model) modelSet.add(r.model);
|
||||
}
|
||||
const modelList = [...modelSet].sort().map((m) => `- ${m}`);
|
||||
|
||||
return {
|
||||
cost: formatCost(cost),
|
||||
date: log.day || '',
|
||||
input: formatNumber(inputTokens),
|
||||
models: modelList.length > 0 ? modelList : ['-'],
|
||||
output: formatNumber(outputTokens),
|
||||
requests: formatNumber(requests),
|
||||
total: formatNumber(totalTokens),
|
||||
};
|
||||
});
|
||||
|
||||
// Total row
|
||||
rows.push({
|
||||
cost: pc.bold(formatCost(sumCost)),
|
||||
date: pc.bold('Total'),
|
||||
input: pc.bold(formatNumber(sumInput)),
|
||||
models: '',
|
||||
output: pc.bold(formatNumber(sumOutput)),
|
||||
requests: pc.bold(formatNumber(sumRequests)),
|
||||
total: pc.bold(formatNumber(sumTotal)),
|
||||
});
|
||||
|
||||
const monthLabel = options.month || new Date().toISOString().slice(0, 7);
|
||||
const mode = options.daily ? 'Daily' : 'Monthly';
|
||||
printBoxTable(columns, rows, `LobeHub Token Usage Report - ${mode} (${monthLabel})`);
|
||||
|
||||
// Calendar heatmap - fetch past 12 months
|
||||
const now = new Date();
|
||||
const rangeStart = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate() + 1);
|
||||
let yearLogs: any[];
|
||||
|
||||
try {
|
||||
// Try single-request endpoint first
|
||||
yearLogs = await client.usage.findAndGroupByDateRange.query({
|
||||
endAt: now.toISOString().slice(0, 10),
|
||||
startAt: rangeStart.toISOString().slice(0, 10),
|
||||
});
|
||||
} catch {
|
||||
// Fallback: fetch each month concurrently
|
||||
const monthKeys: string[] = [];
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
monthKeys.push(d.toISOString().slice(0, 7));
|
||||
}
|
||||
const results = await Promise.all(
|
||||
monthKeys.map((mo) => client.usage.findAndGroupByDay.query({ mo })),
|
||||
);
|
||||
yearLogs = results.flat();
|
||||
}
|
||||
|
||||
const calendarData = (Array.isArray(yearLogs) ? yearLogs : [])
|
||||
.filter((log: any) => log.day)
|
||||
.map((log: any) => ({
|
||||
day: log.day,
|
||||
value: log.totalTokens || 0,
|
||||
}));
|
||||
|
||||
const yearTotal = calendarData.reduce((acc: number, d: any) => acc + d.value, 0);
|
||||
|
||||
printCalendarHeatmap(calendarData, {
|
||||
label: `Past 12 months: ${formatNumber(yearTotal)} tokens`,
|
||||
title: 'Activity (past 12 months)',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { registerAgentCommand } from './commands/agent';
|
||||
@@ -20,12 +22,15 @@ import { registerSkillCommand } from './commands/skill';
|
||||
import { registerStatusCommand } from './commands/status';
|
||||
import { registerTopicCommand } from './commands/topic';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version } = require('../package.json');
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('lh')
|
||||
.description('LobeHub CLI - manage and connect to LobeHub services')
|
||||
.version('0.1.0');
|
||||
.version(version);
|
||||
|
||||
registerLoginCommand(program);
|
||||
registerLogoutCommand(program);
|
||||
|
||||
@@ -24,7 +24,7 @@ vi.mock('../utils/logger', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
describe('file tools', () => {
|
||||
describe('file tools (integration wrapper)', () => {
|
||||
const tmpDir = path.join(os.tmpdir(), 'cli-file-test-' + process.pid);
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -35,424 +35,71 @@ describe('file tools', () => {
|
||||
fs.rmSync(tmpDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe('readLocalFile', () => {
|
||||
it('should read a file with default line range (0-200)', async () => {
|
||||
const filePath = path.join(tmpDir, 'test.txt');
|
||||
const lines = Array.from({ length: 300 }, (_, i) => `line ${i}`);
|
||||
await writeFile(filePath, lines.join('\n'));
|
||||
it('should re-export readLocalFile from shared package', async () => {
|
||||
const filePath = path.join(tmpDir, 'test.txt');
|
||||
await writeFile(filePath, 'hello world');
|
||||
|
||||
const result = await readLocalFile({ path: filePath });
|
||||
const result = await readLocalFile({ path: filePath });
|
||||
|
||||
expect(result.lineCount).toBe(200);
|
||||
expect(result.totalLineCount).toBe(300);
|
||||
expect(result.loc).toEqual([0, 200]);
|
||||
expect(result.filename).toBe('test.txt');
|
||||
expect(result.fileType).toBe('txt');
|
||||
});
|
||||
|
||||
it('should read full content when fullContent is true', async () => {
|
||||
const filePath = path.join(tmpDir, 'full.txt');
|
||||
const lines = Array.from({ length: 300 }, (_, i) => `line ${i}`);
|
||||
await writeFile(filePath, lines.join('\n'));
|
||||
|
||||
const result = await readLocalFile({ fullContent: true, path: filePath });
|
||||
|
||||
expect(result.lineCount).toBe(300);
|
||||
expect(result.loc).toEqual([0, 300]);
|
||||
});
|
||||
|
||||
it('should read specific line range', async () => {
|
||||
const filePath = path.join(tmpDir, 'range.txt');
|
||||
const lines = Array.from({ length: 10 }, (_, i) => `line ${i}`);
|
||||
await writeFile(filePath, lines.join('\n'));
|
||||
|
||||
const result = await readLocalFile({ loc: [2, 5], path: filePath });
|
||||
|
||||
expect(result.lineCount).toBe(3);
|
||||
expect(result.content).toBe('line 2\nline 3\nline 4');
|
||||
expect(result.loc).toEqual([2, 5]);
|
||||
});
|
||||
|
||||
it('should handle non-existent file', async () => {
|
||||
const result = await readLocalFile({ path: path.join(tmpDir, 'nope.txt') });
|
||||
|
||||
expect(result.content).toContain('Error');
|
||||
expect(result.lineCount).toBe(0);
|
||||
expect(result.totalLineCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should detect file type from extension', async () => {
|
||||
const filePath = path.join(tmpDir, 'code.ts');
|
||||
await writeFile(filePath, 'const x = 1;');
|
||||
|
||||
const result = await readLocalFile({ path: filePath });
|
||||
|
||||
expect(result.fileType).toBe('ts');
|
||||
});
|
||||
|
||||
it('should handle file without extension', async () => {
|
||||
const filePath = path.join(tmpDir, 'Makefile');
|
||||
await writeFile(filePath, 'all: build');
|
||||
|
||||
const result = await readLocalFile({ path: filePath });
|
||||
|
||||
expect(result.fileType).toBe('unknown');
|
||||
});
|
||||
expect(result.filename).toBe('test.txt');
|
||||
expect(result.content).toBe('hello world');
|
||||
});
|
||||
|
||||
describe('writeLocalFile', () => {
|
||||
it('should write a file successfully', async () => {
|
||||
const filePath = path.join(tmpDir, 'output.txt');
|
||||
it('should re-export writeLocalFile from shared package', async () => {
|
||||
const filePath = path.join(tmpDir, 'output.txt');
|
||||
|
||||
const result = await writeLocalFile({ content: 'hello world', path: filePath });
|
||||
const result = await writeLocalFile({ content: 'written', path: filePath });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('should create parent directories', async () => {
|
||||
const filePath = path.join(tmpDir, 'sub', 'dir', 'file.txt');
|
||||
|
||||
const result = await writeLocalFile({ content: 'nested', path: filePath });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('nested');
|
||||
});
|
||||
|
||||
it('should return error for empty path', async () => {
|
||||
const result = await writeLocalFile({ content: 'data', path: '' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Path cannot be empty');
|
||||
});
|
||||
|
||||
it('should return error for undefined content', async () => {
|
||||
const result = await writeLocalFile({
|
||||
content: undefined as any,
|
||||
path: path.join(tmpDir, 'f.txt'),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Content cannot be empty');
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('written');
|
||||
});
|
||||
|
||||
describe('editLocalFile', () => {
|
||||
it('should replace first occurrence by default', async () => {
|
||||
const filePath = path.join(tmpDir, 'edit.txt');
|
||||
await writeFile(filePath, 'hello world\nhello again');
|
||||
it('should re-export editLocalFile from shared package', async () => {
|
||||
const filePath = path.join(tmpDir, 'edit.txt');
|
||||
await writeFile(filePath, 'hello world');
|
||||
|
||||
const result = await editLocalFile({
|
||||
file_path: filePath,
|
||||
new_string: 'hi',
|
||||
old_string: 'hello',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.replacements).toBe(1);
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('hi world\nhello again');
|
||||
expect(result.diffText).toBeDefined();
|
||||
expect(result.linesAdded).toBeDefined();
|
||||
expect(result.linesDeleted).toBeDefined();
|
||||
const result = await editLocalFile({
|
||||
file_path: filePath,
|
||||
new_string: 'hi',
|
||||
old_string: 'hello',
|
||||
});
|
||||
|
||||
it('should replace all occurrences when replace_all is true', async () => {
|
||||
const filePath = path.join(tmpDir, 'edit-all.txt');
|
||||
await writeFile(filePath, 'hello world\nhello again');
|
||||
|
||||
const result = await editLocalFile({
|
||||
file_path: filePath,
|
||||
new_string: 'hi',
|
||||
old_string: 'hello',
|
||||
replace_all: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.replacements).toBe(2);
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('hi world\nhi again');
|
||||
});
|
||||
|
||||
it('should return error when old_string not found', async () => {
|
||||
const filePath = path.join(tmpDir, 'no-match.txt');
|
||||
await writeFile(filePath, 'hello world');
|
||||
|
||||
const result = await editLocalFile({
|
||||
file_path: filePath,
|
||||
new_string: 'hi',
|
||||
old_string: 'xyz',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.replacements).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle special regex characters in old_string with replace_all', async () => {
|
||||
const filePath = path.join(tmpDir, 'regex.txt');
|
||||
await writeFile(filePath, 'price is $10.00 and $20.00');
|
||||
|
||||
const result = await editLocalFile({
|
||||
file_path: filePath,
|
||||
new_string: '$XX.XX',
|
||||
old_string: '$10.00',
|
||||
replace_all: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('price is $XX.XX and $20.00');
|
||||
});
|
||||
|
||||
it('should handle file read error', async () => {
|
||||
const result = await editLocalFile({
|
||||
file_path: path.join(tmpDir, 'nonexistent.txt'),
|
||||
new_string: 'new',
|
||||
old_string: 'old',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.replacements).toBe(1);
|
||||
});
|
||||
|
||||
describe('listLocalFiles', () => {
|
||||
it('should list files in directory', async () => {
|
||||
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
|
||||
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
|
||||
await mkdir(path.join(tmpDir, 'subdir'));
|
||||
it('should re-export listLocalFiles from shared package', async () => {
|
||||
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
|
||||
|
||||
const result = await listLocalFiles({ path: tmpDir });
|
||||
const result = await listLocalFiles({ path: tmpDir });
|
||||
|
||||
expect(result.totalCount).toBe(3);
|
||||
expect(result.files.length).toBe(3);
|
||||
const names = result.files.map((f: any) => f.name);
|
||||
expect(names).toContain('a.txt');
|
||||
expect(names).toContain('b.txt');
|
||||
expect(names).toContain('subdir');
|
||||
});
|
||||
|
||||
it('should sort by name ascending', async () => {
|
||||
await writeFile(path.join(tmpDir, 'c.txt'), 'c');
|
||||
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
|
||||
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
|
||||
|
||||
const result = await listLocalFiles({
|
||||
path: tmpDir,
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
|
||||
expect(result.files[0].name).toBe('a.txt');
|
||||
expect(result.files[2].name).toBe('c.txt');
|
||||
});
|
||||
|
||||
it('should sort by size', async () => {
|
||||
await writeFile(path.join(tmpDir, 'small.txt'), 'x');
|
||||
await writeFile(path.join(tmpDir, 'large.txt'), 'x'.repeat(1000));
|
||||
|
||||
const result = await listLocalFiles({
|
||||
path: tmpDir,
|
||||
sortBy: 'size',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
|
||||
expect(result.files[0].name).toBe('small.txt');
|
||||
});
|
||||
|
||||
it('should sort by createdTime', async () => {
|
||||
await writeFile(path.join(tmpDir, 'first.txt'), 'first');
|
||||
// Small delay to ensure different timestamps
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await writeFile(path.join(tmpDir, 'second.txt'), 'second');
|
||||
|
||||
const result = await listLocalFiles({
|
||||
path: tmpDir,
|
||||
sortBy: 'createdTime',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
|
||||
expect(result.files.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should respect limit', async () => {
|
||||
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
|
||||
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
|
||||
await writeFile(path.join(tmpDir, 'c.txt'), 'c');
|
||||
|
||||
const result = await listLocalFiles({ limit: 2, path: tmpDir });
|
||||
|
||||
expect(result.files.length).toBe(2);
|
||||
expect(result.totalCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle non-existent directory', async () => {
|
||||
const result = await listLocalFiles({ path: path.join(tmpDir, 'nope') });
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should use default sortBy for unknown sort key', async () => {
|
||||
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
|
||||
|
||||
const result = await listLocalFiles({
|
||||
path: tmpDir,
|
||||
sortBy: 'unknown' as any,
|
||||
});
|
||||
|
||||
expect(result.files.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should mark directories correctly', async () => {
|
||||
await mkdir(path.join(tmpDir, 'mydir'));
|
||||
|
||||
const result = await listLocalFiles({ path: tmpDir });
|
||||
|
||||
const dir = result.files.find((f: any) => f.name === 'mydir');
|
||||
expect(dir.isDirectory).toBe(true);
|
||||
expect(dir.type).toBe('directory');
|
||||
});
|
||||
expect(result.totalCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe('globLocalFiles', () => {
|
||||
it('should match glob patterns', async () => {
|
||||
await writeFile(path.join(tmpDir, 'a.ts'), 'a');
|
||||
await writeFile(path.join(tmpDir, 'b.ts'), 'b');
|
||||
await writeFile(path.join(tmpDir, 'c.js'), 'c');
|
||||
it('should re-export globLocalFiles from shared package', async () => {
|
||||
await writeFile(path.join(tmpDir, 'a.ts'), 'a');
|
||||
await writeFile(path.join(tmpDir, 'b.js'), 'b');
|
||||
|
||||
const result = await globLocalFiles({ cwd: tmpDir, pattern: '*.ts' });
|
||||
const result = await globLocalFiles({ cwd: tmpDir, pattern: '*.ts' });
|
||||
|
||||
expect(result.files.length).toBe(2);
|
||||
expect(result.files).toContain('a.ts');
|
||||
expect(result.files).toContain('b.ts');
|
||||
});
|
||||
|
||||
it('should ignore node_modules and .git', async () => {
|
||||
await mkdir(path.join(tmpDir, 'node_modules', 'pkg'), { recursive: true });
|
||||
await writeFile(path.join(tmpDir, 'node_modules', 'pkg', 'index.ts'), 'x');
|
||||
await writeFile(path.join(tmpDir, 'src.ts'), 'y');
|
||||
|
||||
const result = await globLocalFiles({ cwd: tmpDir, pattern: '**/*.ts' });
|
||||
|
||||
expect(result.files).toEqual(['src.ts']);
|
||||
});
|
||||
|
||||
it('should use process.cwd() when cwd not specified', async () => {
|
||||
const result = await globLocalFiles({ pattern: '*.nonexistent-ext-xyz' });
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle invalid pattern gracefully', async () => {
|
||||
// fast-glob handles most patterns; test with a simple one
|
||||
const result = await globLocalFiles({ cwd: tmpDir, pattern: '*.txt' });
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
});
|
||||
expect(result.files).toContain('a.ts');
|
||||
expect(result.files).not.toContain('b.js');
|
||||
});
|
||||
|
||||
describe('editLocalFile edge cases', () => {
|
||||
it('should count lines added and deleted', async () => {
|
||||
const filePath = path.join(tmpDir, 'multiline.txt');
|
||||
await writeFile(filePath, 'line1\nline2\nline3');
|
||||
it('should re-export grepContent from shared package', async () => {
|
||||
await writeFile(path.join(tmpDir, 'search.txt'), 'hello world');
|
||||
|
||||
const result = await editLocalFile({
|
||||
file_path: filePath,
|
||||
new_string: 'newA\nnewB\nnewC\nnewD',
|
||||
old_string: 'line2',
|
||||
});
|
||||
const result = await grepContent({ cwd: tmpDir, pattern: 'hello' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.linesAdded).toBeGreaterThan(0);
|
||||
expect(result.linesDeleted).toBeGreaterThan(0);
|
||||
});
|
||||
expect(result).toHaveProperty('success');
|
||||
expect(result).toHaveProperty('matches');
|
||||
});
|
||||
|
||||
describe('grepContent', () => {
|
||||
it('should return matches using ripgrep', async () => {
|
||||
await writeFile(path.join(tmpDir, 'search.txt'), 'hello world\nfoo bar\nhello again');
|
||||
it('should re-export searchLocalFiles from shared package', async () => {
|
||||
await writeFile(path.join(tmpDir, 'config.json'), '{}');
|
||||
|
||||
const result = await grepContent({ cwd: tmpDir, pattern: 'hello' });
|
||||
const result = await searchLocalFiles({ directory: tmpDir, keywords: 'config' });
|
||||
|
||||
// Result depends on whether rg is installed
|
||||
expect(result).toHaveProperty('success');
|
||||
expect(result).toHaveProperty('matches');
|
||||
});
|
||||
|
||||
it('should support file pattern filter', async () => {
|
||||
await writeFile(path.join(tmpDir, 'test.ts'), 'const x = 1;');
|
||||
await writeFile(path.join(tmpDir, 'test.js'), 'const y = 2;');
|
||||
|
||||
const result = await grepContent({
|
||||
cwd: tmpDir,
|
||||
filePattern: '*.ts',
|
||||
pattern: 'const',
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('success');
|
||||
});
|
||||
|
||||
it('should handle no matches', async () => {
|
||||
await writeFile(path.join(tmpDir, 'empty.txt'), 'nothing here');
|
||||
|
||||
const result = await grepContent({ cwd: tmpDir, pattern: 'xyz_not_found' });
|
||||
|
||||
expect(result.matches).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchLocalFiles', () => {
|
||||
it('should find files by keyword', async () => {
|
||||
await writeFile(path.join(tmpDir, 'config.json'), '{}');
|
||||
await writeFile(path.join(tmpDir, 'config.yaml'), '');
|
||||
await writeFile(path.join(tmpDir, 'readme.md'), '');
|
||||
|
||||
const result = await searchLocalFiles({ directory: tmpDir, keywords: 'config' });
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.map((r: any) => r.name)).toContain('config.json');
|
||||
});
|
||||
|
||||
it('should filter by content', async () => {
|
||||
await writeFile(path.join(tmpDir, 'match.txt'), 'this has the secret');
|
||||
await writeFile(path.join(tmpDir, 'nomatch.txt'), 'nothing here');
|
||||
|
||||
// Search with a broad pattern and content filter
|
||||
const result = await searchLocalFiles({
|
||||
contentContains: 'secret',
|
||||
directory: tmpDir,
|
||||
keywords: '',
|
||||
});
|
||||
|
||||
// Content filtering should exclude files without 'secret'
|
||||
expect(result.every((r: any) => r.name !== 'nomatch.txt' || false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect limit', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await writeFile(path.join(tmpDir, `file${i}.log`), `content ${i}`);
|
||||
}
|
||||
|
||||
const result = await searchLocalFiles({
|
||||
directory: tmpDir,
|
||||
keywords: 'file',
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should use cwd when directory not specified', async () => {
|
||||
const result = await searchLocalFiles({ keywords: 'nonexistent_xyz_file' });
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const result = await searchLocalFiles({
|
||||
directory: '/nonexistent/path/xyz',
|
||||
keywords: 'test',
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
expect(result.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,357 +1,9 @@
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createPatch } from 'diff';
|
||||
import fg from 'fast-glob';
|
||||
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
// ─── readLocalFile ───
|
||||
|
||||
interface ReadFileParams {
|
||||
fullContent?: boolean;
|
||||
loc?: [number, number];
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function readLocalFile({ path: filePath, loc, fullContent }: ReadFileParams) {
|
||||
const effectiveLoc = fullContent ? undefined : (loc ?? [0, 200]);
|
||||
log.debug(`Reading file: ${filePath}, loc=${JSON.stringify(effectiveLoc)}`);
|
||||
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const totalLineCount = lines.length;
|
||||
const totalCharCount = content.length;
|
||||
|
||||
let selectedContent: string;
|
||||
let lineCount: number;
|
||||
let actualLoc: [number, number];
|
||||
|
||||
if (effectiveLoc === undefined) {
|
||||
selectedContent = content;
|
||||
lineCount = totalLineCount;
|
||||
actualLoc = [0, totalLineCount];
|
||||
} else {
|
||||
const [startLine, endLine] = effectiveLoc;
|
||||
const selectedLines = lines.slice(startLine, endLine);
|
||||
selectedContent = selectedLines.join('\n');
|
||||
lineCount = selectedLines.length;
|
||||
actualLoc = effectiveLoc;
|
||||
}
|
||||
|
||||
const fileStat = await stat(filePath);
|
||||
|
||||
return {
|
||||
charCount: selectedContent.length,
|
||||
content: selectedContent,
|
||||
createdTime: fileStat.birthtime,
|
||||
fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown',
|
||||
filename: path.basename(filePath),
|
||||
lineCount,
|
||||
loc: actualLoc,
|
||||
modifiedTime: fileStat.mtime,
|
||||
totalCharCount,
|
||||
totalLineCount,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message;
|
||||
return {
|
||||
charCount: 0,
|
||||
content: `Error accessing or processing file: ${errorMessage}`,
|
||||
createdTime: new Date(),
|
||||
fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown',
|
||||
filename: path.basename(filePath),
|
||||
lineCount: 0,
|
||||
loc: [0, 0] as [number, number],
|
||||
modifiedTime: new Date(),
|
||||
totalCharCount: 0,
|
||||
totalLineCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── writeLocalFile ───
|
||||
|
||||
interface WriteFileParams {
|
||||
content: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function writeLocalFile({ path: filePath, content }: WriteFileParams) {
|
||||
if (!filePath) return { error: 'Path cannot be empty', success: false };
|
||||
if (content === undefined) return { error: 'Content cannot be empty', success: false };
|
||||
|
||||
try {
|
||||
const dirname = path.dirname(filePath);
|
||||
await mkdir(dirname, { recursive: true });
|
||||
await writeFile(filePath, content, 'utf8');
|
||||
log.debug(`File written: ${filePath} (${content.length} chars)`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: `Failed to write file: ${(error as Error).message}`, success: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── editLocalFile ───
|
||||
|
||||
interface EditFileParams {
|
||||
file_path: string;
|
||||
new_string: string;
|
||||
old_string: string;
|
||||
replace_all?: boolean;
|
||||
}
|
||||
|
||||
export async function editLocalFile({
|
||||
file_path: filePath,
|
||||
old_string,
|
||||
new_string,
|
||||
replace_all = false,
|
||||
}: EditFileParams) {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
|
||||
if (!content.includes(old_string)) {
|
||||
return {
|
||||
error: 'The specified old_string was not found in the file',
|
||||
replacements: 0,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
let newContent: string;
|
||||
let replacements: number;
|
||||
|
||||
if (replace_all) {
|
||||
const regex = new RegExp(old_string.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'), 'g');
|
||||
const matches = content.match(regex);
|
||||
replacements = matches ? matches.length : 0;
|
||||
newContent = content.replaceAll(old_string, new_string);
|
||||
} else {
|
||||
const index = content.indexOf(old_string);
|
||||
if (index === -1) {
|
||||
return { error: 'Old string not found', replacements: 0, success: false };
|
||||
}
|
||||
newContent = content.slice(0, index) + new_string + content.slice(index + old_string.length);
|
||||
replacements = 1;
|
||||
}
|
||||
|
||||
await writeFile(filePath, newContent, 'utf8');
|
||||
|
||||
const patch = createPatch(filePath, content, newContent, '', '');
|
||||
const diffText = `diff --git a${filePath} b${filePath}\n${patch}`;
|
||||
|
||||
const patchLines = patch.split('\n');
|
||||
let linesAdded = 0;
|
||||
let linesDeleted = 0;
|
||||
|
||||
for (const line of patchLines) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) linesAdded++;
|
||||
else if (line.startsWith('-') && !line.startsWith('---')) linesDeleted++;
|
||||
}
|
||||
|
||||
return { diffText, linesAdded, linesDeleted, replacements, success: true };
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message, replacements: 0, success: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── listLocalFiles ───
|
||||
|
||||
interface ListFilesParams {
|
||||
limit?: number;
|
||||
path: string;
|
||||
sortBy?: 'createdTime' | 'modifiedTime' | 'name' | 'size';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export async function listLocalFiles({
|
||||
path: dirPath,
|
||||
sortBy = 'modifiedTime',
|
||||
sortOrder = 'desc',
|
||||
limit = 100,
|
||||
}: ListFilesParams) {
|
||||
try {
|
||||
const entries = await readdir(dirPath);
|
||||
const results: any[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry);
|
||||
try {
|
||||
const stats = await stat(fullPath);
|
||||
const isDirectory = stats.isDirectory();
|
||||
results.push({
|
||||
createdTime: stats.birthtime,
|
||||
isDirectory,
|
||||
lastAccessTime: stats.atime,
|
||||
modifiedTime: stats.mtime,
|
||||
name: entry,
|
||||
path: fullPath,
|
||||
size: stats.size,
|
||||
type: isDirectory ? 'directory' : path.extname(entry).toLowerCase().replace('.', ''),
|
||||
});
|
||||
} catch {
|
||||
// Skip files we can't stat
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => {
|
||||
let comparison: number;
|
||||
switch (sortBy) {
|
||||
case 'name': {
|
||||
comparison = (a.name || '').localeCompare(b.name || '');
|
||||
break;
|
||||
}
|
||||
case 'modifiedTime': {
|
||||
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
|
||||
break;
|
||||
}
|
||||
case 'createdTime': {
|
||||
comparison = a.createdTime.getTime() - b.createdTime.getTime();
|
||||
break;
|
||||
}
|
||||
case 'size': {
|
||||
comparison = a.size - b.size;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
|
||||
}
|
||||
}
|
||||
return sortOrder === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
|
||||
const totalCount = results.length;
|
||||
return { files: results.slice(0, limit), totalCount };
|
||||
} catch (error) {
|
||||
log.error(`Failed to list directory ${dirPath}:`, error);
|
||||
return { files: [], totalCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── globLocalFiles ───
|
||||
|
||||
interface GlobFilesParams {
|
||||
cwd?: string;
|
||||
pattern: string;
|
||||
}
|
||||
|
||||
export async function globLocalFiles({ pattern, cwd }: GlobFilesParams) {
|
||||
try {
|
||||
const files = await fg(pattern, {
|
||||
cwd: cwd || process.cwd(),
|
||||
dot: false,
|
||||
ignore: ['**/node_modules/**', '**/.git/**'],
|
||||
});
|
||||
return { files };
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message, files: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── grepContent ───
|
||||
|
||||
interface GrepContentParams {
|
||||
cwd?: string;
|
||||
filePattern?: string;
|
||||
pattern: string;
|
||||
}
|
||||
|
||||
export async function grepContent({ pattern, cwd, filePattern }: GrepContentParams) {
|
||||
const { spawn } = await import('node:child_process');
|
||||
|
||||
return new Promise<{ matches: any[]; success: boolean }>((resolve) => {
|
||||
const args = ['--json', '-n'];
|
||||
if (filePattern) args.push('--glob', filePattern);
|
||||
args.push(pattern);
|
||||
|
||||
const child = spawn('rg', args, { cwd: cwd || process.cwd() });
|
||||
let stdout = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
child.stderr?.on('data', () => {
|
||||
// stderr consumed but not used
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0 && code !== 1) {
|
||||
// Fallback: use simple regex search
|
||||
log.debug('rg not available, falling back to simple search');
|
||||
resolve({ matches: [], success: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const matches = stdout
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
resolve({ matches, success: true });
|
||||
} catch {
|
||||
resolve({ matches: [], success: true });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
log.debug('rg not available');
|
||||
resolve({ matches: [], success: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── searchLocalFiles ───
|
||||
|
||||
interface SearchFilesParams {
|
||||
contentContains?: string;
|
||||
directory?: string;
|
||||
keywords: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function searchLocalFiles({
|
||||
keywords,
|
||||
directory,
|
||||
contentContains,
|
||||
limit = 30,
|
||||
}: SearchFilesParams) {
|
||||
try {
|
||||
const cwd = directory || process.cwd();
|
||||
const files = await fg(`**/*${keywords}*`, {
|
||||
cwd,
|
||||
dot: false,
|
||||
ignore: ['**/node_modules/**', '**/.git/**'],
|
||||
});
|
||||
|
||||
let results = files.map((f) => ({ name: path.basename(f), path: path.join(cwd, f) }));
|
||||
|
||||
if (contentContains) {
|
||||
const filtered: typeof results = [];
|
||||
for (const file of results) {
|
||||
try {
|
||||
const content = await readFile(file.path, 'utf8');
|
||||
if (content.includes(contentContains)) {
|
||||
filtered.push(file);
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
results = filtered;
|
||||
}
|
||||
|
||||
return results.slice(0, limit);
|
||||
} catch (error) {
|
||||
log.error('File search failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export {
|
||||
editLocalFile,
|
||||
globLocalFiles,
|
||||
grepContent,
|
||||
listLocalFiles,
|
||||
readLocalFile,
|
||||
searchLocalFiles,
|
||||
writeLocalFile,
|
||||
} from '@lobechat/local-file-shell';
|
||||
|
||||
@@ -11,227 +11,55 @@ vi.mock('../utils/logger', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
describe('shell tools', () => {
|
||||
describe('shell tools (integration wrapper)', () => {
|
||||
afterEach(() => {
|
||||
cleanupAllProcesses();
|
||||
});
|
||||
|
||||
describe('runCommand', () => {
|
||||
it('should execute a simple command', async () => {
|
||||
const result = await runCommand({ command: 'echo hello' });
|
||||
it('should delegate runCommand to shared package', async () => {
|
||||
const result = await runCommand({ command: 'echo hello' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('hello');
|
||||
expect(result.exit_code).toBe(0);
|
||||
});
|
||||
|
||||
it('should capture stderr', async () => {
|
||||
const result = await runCommand({ command: 'echo error >&2' });
|
||||
|
||||
expect(result.stderr).toContain('error');
|
||||
});
|
||||
|
||||
it('should handle command failure', async () => {
|
||||
const result = await runCommand({ command: 'exit 1' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.exit_code).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle command not found', async () => {
|
||||
const result = await runCommand({ command: 'nonexistent_command_xyz_123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should timeout long-running commands', async () => {
|
||||
const result = await runCommand({ command: 'sleep 10', timeout: 500 });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('timed out');
|
||||
}, 10000);
|
||||
|
||||
it('should clamp timeout to minimum 1000ms', async () => {
|
||||
const result = await runCommand({ command: 'echo fast', timeout: 100 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should run command in background', async () => {
|
||||
const result = await runCommand({
|
||||
command: 'echo background',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.shell_id).toBeDefined();
|
||||
});
|
||||
|
||||
it('should strip ANSI codes from output', async () => {
|
||||
const result = await runCommand({
|
||||
command: 'printf "\\033[31mred\\033[0m"',
|
||||
});
|
||||
|
||||
expect(result.output).not.toContain('\u001B');
|
||||
});
|
||||
|
||||
it('should truncate very long output', async () => {
|
||||
// Generate output longer than 80KB
|
||||
const result = await runCommand({
|
||||
command: `python3 -c "print('x' * 100000)" 2>/dev/null || printf '%0.sx' $(seq 1 100000)`,
|
||||
});
|
||||
|
||||
// Output should be truncated
|
||||
expect(result.output.length).toBeLessThanOrEqual(85000); // 80000 + truncation message
|
||||
}, 15000);
|
||||
|
||||
it('should use description in log prefix', async () => {
|
||||
const result = await runCommand({
|
||||
command: 'echo test',
|
||||
description: 'test command',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('hello');
|
||||
});
|
||||
|
||||
describe('getCommandOutput', () => {
|
||||
it('should get output from background process', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'echo hello && sleep 0.1',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
// Wait for output to be captured
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
const output = await getCommandOutput({ shell_id: bgResult.shell_id });
|
||||
|
||||
expect(output.success).toBe(true);
|
||||
expect(output.stdout).toContain('hello');
|
||||
it('should delegate background commands and getCommandOutput', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'echo background && sleep 0.1',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
it('should return error for unknown shell_id', async () => {
|
||||
const result = await getCommandOutput({ shell_id: 'unknown-id' });
|
||||
expect(bgResult.success).toBe(true);
|
||||
expect(bgResult.shell_id).toBeDefined();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
it('should track running state', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'sleep 5',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
const output = await getCommandOutput({ shell_id: bgResult.shell_id });
|
||||
|
||||
expect(output.running).toBe(true);
|
||||
});
|
||||
|
||||
it('should support filter parameter', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'echo "line1\nline2\nline3"',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
const output = await getCommandOutput({
|
||||
filter: 'line2',
|
||||
shell_id: bgResult.shell_id,
|
||||
});
|
||||
|
||||
expect(output.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle invalid filter regex', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'echo test',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
const output = await getCommandOutput({
|
||||
filter: '[invalid',
|
||||
shell_id: bgResult.shell_id,
|
||||
});
|
||||
|
||||
expect(output.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should return new output only on subsequent calls', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'echo first && sleep 0.2 && echo second',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const first = await getCommandOutput({ shell_id: bgResult.shell_id });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await getCommandOutput({ shell_id: bgResult.shell_id });
|
||||
|
||||
// First read should have "first"
|
||||
expect(first.stdout).toContain('first');
|
||||
});
|
||||
const output = await getCommandOutput({ shell_id: bgResult.shell_id! });
|
||||
expect(output.success).toBe(true);
|
||||
expect(output.stdout).toContain('background');
|
||||
});
|
||||
|
||||
describe('killCommand', () => {
|
||||
it('should kill a background process', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'sleep 60',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
const result = await killCommand({ shell_id: bgResult.shell_id });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
it('should delegate killCommand', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'sleep 60',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
it('should return error for unknown shell_id', async () => {
|
||||
const result = await killCommand({ shell_id: 'unknown-id' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
const result = await killCommand({ shell_id: bgResult.shell_id! });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
describe('killCommand error handling', () => {
|
||||
it('should handle kill error on already-dead process', async () => {
|
||||
const bgResult = await runCommand({
|
||||
command: 'echo done',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
// Wait for process to finish
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
// Process is already done, killing should still succeed or return error
|
||||
const result = await killCommand({ shell_id: bgResult.shell_id });
|
||||
// It may succeed (process already exited) or fail, but shouldn't throw
|
||||
expect(result).toHaveProperty('success');
|
||||
});
|
||||
it('should return error for unknown shell_id', async () => {
|
||||
const result = await getCommandOutput({ shell_id: 'unknown-id' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
|
||||
describe('runCommand error handling', () => {
|
||||
it('should handle spawn error for non-existent shell', async () => {
|
||||
// Test with a command that causes spawn error
|
||||
const result = await runCommand({ command: 'echo test' });
|
||||
// Normal command should work
|
||||
expect(result).toHaveProperty('success');
|
||||
});
|
||||
});
|
||||
it('should cleanup all processes', async () => {
|
||||
await runCommand({ command: 'sleep 60', run_in_background: true });
|
||||
await runCommand({ command: 'sleep 60', run_in_background: true });
|
||||
|
||||
describe('cleanupAllProcesses', () => {
|
||||
it('should kill all background processes', async () => {
|
||||
await runCommand({ command: 'sleep 60', run_in_background: true });
|
||||
await runCommand({ command: 'sleep 60', run_in_background: true });
|
||||
|
||||
cleanupAllProcesses();
|
||||
|
||||
// No processes should remain - subsequent getCommandOutput should fail
|
||||
});
|
||||
cleanupAllProcesses();
|
||||
// No assertion needed — verifies no throw
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,233 +1,27 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
type GetCommandOutputParams,
|
||||
type KillCommandParams,
|
||||
runCommand as runCommandCore,
|
||||
type RunCommandParams,
|
||||
ShellProcessManager,
|
||||
} from '@lobechat/local-file-shell';
|
||||
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
// Maximum output length to prevent context explosion
|
||||
const MAX_OUTPUT_LENGTH = 80_000;
|
||||
|
||||
const ANSI_REGEX =
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/\u001B(?:[\u0040-\u005A\u005C-\u005F]|\[[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E])/g;
|
||||
const stripAnsi = (str: string): string => str.replaceAll(ANSI_REGEX, '');
|
||||
|
||||
const truncateOutput = (str: string, maxLength: number = MAX_OUTPUT_LENGTH): string => {
|
||||
const cleaned = stripAnsi(str);
|
||||
if (cleaned.length <= maxLength) return cleaned;
|
||||
return (
|
||||
cleaned.slice(0, maxLength) +
|
||||
'\n... [truncated, ' +
|
||||
(cleaned.length - maxLength) +
|
||||
' more characters]'
|
||||
);
|
||||
};
|
||||
|
||||
interface ShellProcess {
|
||||
lastReadStderr: number;
|
||||
lastReadStdout: number;
|
||||
process: ChildProcess;
|
||||
stderr: string[];
|
||||
stdout: string[];
|
||||
}
|
||||
|
||||
const shellProcesses = new Map<string, ShellProcess>();
|
||||
const processManager = new ShellProcessManager();
|
||||
|
||||
export function cleanupAllProcesses() {
|
||||
for (const [id, sp] of shellProcesses) {
|
||||
try {
|
||||
sp.process.kill();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
shellProcesses.delete(id);
|
||||
}
|
||||
processManager.cleanupAll();
|
||||
}
|
||||
|
||||
// ─── runCommand ───
|
||||
|
||||
interface RunCommandParams {
|
||||
command: string;
|
||||
description?: string;
|
||||
run_in_background?: boolean;
|
||||
timeout?: number;
|
||||
export async function runCommand(params: RunCommandParams) {
|
||||
return runCommandCore(params, { logger: log, processManager });
|
||||
}
|
||||
|
||||
export async function runCommand({
|
||||
command,
|
||||
description,
|
||||
run_in_background,
|
||||
timeout = 120_000,
|
||||
}: RunCommandParams) {
|
||||
const logPrefix = `[runCommand: ${description || command.slice(0, 50)}]`;
|
||||
log.debug(`${logPrefix} Starting`, { background: run_in_background, timeout });
|
||||
|
||||
const effectiveTimeout = Math.min(Math.max(timeout, 1000), 600_000);
|
||||
|
||||
const shellConfig =
|
||||
process.platform === 'win32'
|
||||
? { args: ['/c', command], cmd: 'cmd.exe' }
|
||||
: { args: ['-c', command], cmd: '/bin/sh' };
|
||||
|
||||
try {
|
||||
if (run_in_background) {
|
||||
const shellId = randomUUID();
|
||||
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
|
||||
env: process.env,
|
||||
shell: false,
|
||||
});
|
||||
|
||||
const shellProcess: ShellProcess = {
|
||||
lastReadStderr: 0,
|
||||
lastReadStdout: 0,
|
||||
process: childProcess,
|
||||
stderr: [],
|
||||
stdout: [],
|
||||
};
|
||||
|
||||
childProcess.stdout?.on('data', (data) => {
|
||||
shellProcess.stdout.push(data.toString());
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data) => {
|
||||
shellProcess.stderr.push(data.toString());
|
||||
});
|
||||
|
||||
childProcess.on('exit', (code) => {
|
||||
log.debug(`${logPrefix} Background process exited`, { code, shellId });
|
||||
});
|
||||
|
||||
shellProcesses.set(shellId, shellProcess);
|
||||
|
||||
log.debug(`${logPrefix} Started background`, { shellId });
|
||||
return { shell_id: shellId, success: true };
|
||||
} else {
|
||||
return new Promise<any>((resolve) => {
|
||||
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
|
||||
env: process.env,
|
||||
shell: false,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let killed = false;
|
||||
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
killed = true;
|
||||
childProcess.kill();
|
||||
resolve({
|
||||
error: `Command timed out after ${effectiveTimeout}ms`,
|
||||
stderr: truncateOutput(stderr),
|
||||
stdout: truncateOutput(stdout),
|
||||
success: false,
|
||||
});
|
||||
}, effectiveTimeout);
|
||||
|
||||
childProcess.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on('exit', (code) => {
|
||||
if (!killed) {
|
||||
clearTimeout(timeoutHandle);
|
||||
const success = code === 0;
|
||||
resolve({
|
||||
exit_code: code || 0,
|
||||
output: truncateOutput(stdout + stderr),
|
||||
stderr: truncateOutput(stderr),
|
||||
stdout: truncateOutput(stdout),
|
||||
success,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('error', (error) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
resolve({
|
||||
error: error.message,
|
||||
stderr: truncateOutput(stderr),
|
||||
stdout: truncateOutput(stdout),
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message, success: false };
|
||||
}
|
||||
export async function getCommandOutput(params: GetCommandOutputParams) {
|
||||
return processManager.getOutput(params);
|
||||
}
|
||||
|
||||
// ─── getCommandOutput ───
|
||||
|
||||
interface GetCommandOutputParams {
|
||||
filter?: string;
|
||||
shell_id: string;
|
||||
}
|
||||
|
||||
export async function getCommandOutput({ shell_id, filter }: GetCommandOutputParams) {
|
||||
const shellProcess = shellProcesses.get(shell_id);
|
||||
if (!shellProcess) {
|
||||
return {
|
||||
error: `Shell ID ${shell_id} not found`,
|
||||
output: '',
|
||||
running: false,
|
||||
stderr: '',
|
||||
stdout: '',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { lastReadStderr, lastReadStdout, process: childProcess, stderr, stdout } = shellProcess;
|
||||
|
||||
const newStdout = stdout.slice(lastReadStdout).join('');
|
||||
const newStderr = stderr.slice(lastReadStderr).join('');
|
||||
let output = newStdout + newStderr;
|
||||
|
||||
if (filter) {
|
||||
try {
|
||||
const regex = new RegExp(filter, 'gm');
|
||||
const lines = output.split('\n');
|
||||
output = lines.filter((line) => regex.test(line)).join('\n');
|
||||
} catch {
|
||||
// Invalid filter regex, use unfiltered output
|
||||
}
|
||||
}
|
||||
|
||||
shellProcess.lastReadStdout = stdout.length;
|
||||
shellProcess.lastReadStderr = stderr.length;
|
||||
|
||||
const running = childProcess.exitCode === null;
|
||||
|
||||
return {
|
||||
output: truncateOutput(output),
|
||||
running,
|
||||
stderr: truncateOutput(newStderr),
|
||||
stdout: truncateOutput(newStdout),
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── killCommand ───
|
||||
|
||||
interface KillCommandParams {
|
||||
shell_id: string;
|
||||
}
|
||||
|
||||
export async function killCommand({ shell_id }: KillCommandParams) {
|
||||
const shellProcess = shellProcesses.get(shell_id);
|
||||
if (!shellProcess) {
|
||||
return { error: `Shell ID ${shell_id} not found`, success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
shellProcess.process.kill();
|
||||
shellProcesses.delete(shell_id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message, success: false };
|
||||
}
|
||||
export async function killCommand(params: KillCommandParams) {
|
||||
return processManager.kill(params.shell_id);
|
||||
}
|
||||
|
||||
47
apps/cli/src/utils/__snapshots__/format.test.ts.snap
Normal file
47
apps/cli/src/utils/__snapshots__/format.test.ts.snap
Normal file
@@ -0,0 +1,47 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`printBoxTable > should render a basic table 1`] = `
|
||||
"┌───────┬───────┐
|
||||
│ Name │ Count │
|
||||
├───────┼───────┤
|
||||
│ Alice │ 100 │
|
||||
├───────┼───────┤
|
||||
│ Bob │ 2,345 │
|
||||
└───────┴───────┘"
|
||||
`;
|
||||
|
||||
exports[`printBoxTable > should render a table with title and multi-line cells 1`] = `
|
||||
"
|
||||
╭─────────────────────────────────────────────────╮
|
||||
│ Test Report │
|
||||
╰─────────────────────────────────────────────────╯
|
||||
|
||||
┌────────────┬───────────────────┬────────┬───────┐
|
||||
│ Date │ Models │ Total │ Cost │
|
||||
│ │ │ Tokens │ (USD) │
|
||||
├────────────┼───────────────────┼────────┼───────┤
|
||||
│ 2026-03-01 │ - claude-opus-4-6 │ 19,134 │ $1.23 │
|
||||
│ │ - gpt-4o │ │ │
|
||||
├────────────┼───────────────────┼────────┼───────┤
|
||||
│ 2026-03-02 │ - claude-opus-4-6 │ 5,678 │ $0.45 │
|
||||
└────────────┴───────────────────┴────────┴───────┘"
|
||||
`;
|
||||
|
||||
exports[`printBoxTable > should render the usage table format 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ LobeHub Token Usage Report - Monthly (2026-03) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
┌────────────┬────────────────────────┬───────────┬─────────┬───────────┬──────────┬───────┐
|
||||
│ Date │ Models │ Input │ Output │ Total │ Requests │ Cost │
|
||||
│ │ │ │ │ Tokens │ │ (USD) │
|
||||
├────────────┼────────────────────────┼───────────┼─────────┼───────────┼──────────┼───────┤
|
||||
│ 2026-03-01 │ - claude-opus-4-6 │ 4,190,339 │ 121,035 │ 4,311,374 │ 69 │ $3.56 │
|
||||
│ │ - gemini-3-pro-preview │ │ │ │ │ │
|
||||
├────────────┼────────────────────────┼───────────┼─────────┼───────────┼──────────┼───────┤
|
||||
│ 2026-03-02 │ - claude-opus-4-6 │ 4,575,189 │ 34,885 │ 4,610,074 │ 62 │ $4.75 │
|
||||
├────────────┼────────────────────────┼───────────┼─────────┼───────────┼──────────┼───────┤
|
||||
│ Total │ │ 8,765,528 │ 155,920 │ 8,921,448 │ 131 │ $8.31 │
|
||||
└────────────┴────────────────────────┴───────────┴─────────┴───────────┴──────────┴───────┘"
|
||||
`;
|
||||
129
apps/cli/src/utils/format.test.ts
Normal file
129
apps/cli/src/utils/format.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { formatCost, formatNumber, printBoxTable } from './format';
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('should format numbers with commas', () => {
|
||||
expect(formatNumber(0)).toBe('0');
|
||||
expect(formatNumber(1234)).toBe('1,234');
|
||||
expect(formatNumber(1_234_567)).toBe('1,234,567');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCost', () => {
|
||||
it('should format cost with dollar sign', () => {
|
||||
expect(formatCost(0)).toBe('$0.00');
|
||||
expect(formatCost(1.5)).toBe('$1.50');
|
||||
expect(formatCost(123.456)).toBe('$123.46');
|
||||
});
|
||||
});
|
||||
|
||||
describe('printBoxTable', () => {
|
||||
it('should render a basic table', () => {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((...args: any[]) => {
|
||||
output.push(args.join(' '));
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ align: 'left' as const, header: 'Name', key: 'name' },
|
||||
{ align: 'right' as const, header: 'Count', key: 'count' },
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{ count: '100', name: 'Alice' },
|
||||
{ count: '2,345', name: 'Bob' },
|
||||
];
|
||||
|
||||
printBoxTable(columns, rows);
|
||||
expect(output.join('\n')).toMatchSnapshot();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render a table with title and multi-line cells', () => {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((...args: any[]) => {
|
||||
output.push(args.join(' '));
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ align: 'left' as const, header: 'Date', key: 'date' },
|
||||
{ align: 'left' as const, header: 'Models', key: 'models' },
|
||||
{ align: 'right' as const, header: ['Total', 'Tokens'], key: 'total' },
|
||||
{ align: 'right' as const, header: ['Cost', '(USD)'], key: 'cost' },
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{
|
||||
cost: '$1.23',
|
||||
date: '2026-03-01',
|
||||
models: ['- claude-opus-4-6', '- gpt-4o'],
|
||||
total: '19,134',
|
||||
},
|
||||
{
|
||||
cost: '$0.45',
|
||||
date: '2026-03-02',
|
||||
models: ['- claude-opus-4-6'],
|
||||
total: '5,678',
|
||||
},
|
||||
];
|
||||
|
||||
printBoxTable(columns, rows, 'Test Report');
|
||||
expect(output.join('\n')).toMatchSnapshot();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render the usage table format', () => {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((...args: any[]) => {
|
||||
output.push(args.join(' '));
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ align: 'left' as const, header: 'Date', key: 'date' },
|
||||
{ align: 'left' as const, header: 'Models', key: 'models' },
|
||||
{ align: 'right' as const, header: 'Input', key: 'input' },
|
||||
{ align: 'right' as const, header: 'Output', key: 'output' },
|
||||
{ align: 'right' as const, header: ['Total', 'Tokens'], key: 'total' },
|
||||
{ align: 'right' as const, header: 'Requests', key: 'requests' },
|
||||
{ align: 'right' as const, header: ['Cost', '(USD)'], key: 'cost' },
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{
|
||||
cost: '$3.56',
|
||||
date: '2026-03-01',
|
||||
input: '4,190,339',
|
||||
models: ['- claude-opus-4-6', '- gemini-3-pro-preview'],
|
||||
output: '121,035',
|
||||
requests: '69',
|
||||
total: '4,311,374',
|
||||
},
|
||||
{
|
||||
cost: '$4.75',
|
||||
date: '2026-03-02',
|
||||
input: '4,575,189',
|
||||
models: ['- claude-opus-4-6'],
|
||||
output: '34,885',
|
||||
requests: '62',
|
||||
total: '4,610,074',
|
||||
},
|
||||
{
|
||||
cost: '$8.31',
|
||||
date: 'Total',
|
||||
input: '8,765,528',
|
||||
models: '',
|
||||
output: '155,920',
|
||||
requests: '131',
|
||||
total: '8,921,448',
|
||||
},
|
||||
];
|
||||
|
||||
printBoxTable(columns, rows, 'LobeHub Token Usage Report - Monthly (2026-03)');
|
||||
expect(output.join('\n')).toMatchSnapshot();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -15,24 +15,222 @@ export function timeAgo(date: Date | string): string {
|
||||
return `${seconds}s ago`;
|
||||
}
|
||||
|
||||
export function truncate(str: string, len: number): string {
|
||||
if (str.length <= len) return str;
|
||||
return str.slice(0, len - 1) + '…';
|
||||
export function truncate(str: string, maxWidth: number): string {
|
||||
let width = 0;
|
||||
let i = 0;
|
||||
for (const char of str) {
|
||||
const code = char.codePointAt(0)!;
|
||||
const cw =
|
||||
(code >= 0x1100 && code <= 0x115f) ||
|
||||
(code >= 0x2e80 && code <= 0x303e) ||
|
||||
(code >= 0x3040 && code <= 0x33bf) ||
|
||||
(code >= 0x3400 && code <= 0x4dbf) ||
|
||||
(code >= 0x4e00 && code <= 0x9fff) ||
|
||||
(code >= 0xa000 && code <= 0xa4cf) ||
|
||||
(code >= 0xac00 && code <= 0xd7af) ||
|
||||
(code >= 0xf900 && code <= 0xfaff) ||
|
||||
(code >= 0xfe30 && code <= 0xfe6f) ||
|
||||
(code >= 0xff01 && code <= 0xff60) ||
|
||||
(code >= 0xffe0 && code <= 0xffe6) ||
|
||||
(code >= 0x20000 && code <= 0x2fa1f)
|
||||
? 2
|
||||
: 1;
|
||||
if (width + cw > maxWidth - 1) {
|
||||
return str.slice(0, i) + '…';
|
||||
}
|
||||
width += cw;
|
||||
i += char.length;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export function printTable(rows: string[][], header: string[]) {
|
||||
const allRows = [header, ...rows];
|
||||
const colWidths = header.map((_, i) => Math.max(...allRows.map((r) => (r[i] || '').length)));
|
||||
const colWidths = header.map((_, i) => Math.max(...allRows.map((r) => displayWidth(r[i] || ''))));
|
||||
|
||||
const headerLine = header.map((h, i) => h.padEnd(colWidths[i])).join(' ');
|
||||
const headerLine = header.map((h, i) => padDisplay(h, colWidths[i])).join(' ');
|
||||
console.log(pc.bold(headerLine));
|
||||
|
||||
for (const row of rows) {
|
||||
const line = row.map((cell, i) => (cell || '').padEnd(colWidths[i])).join(' ');
|
||||
const line = row.map((cell, i) => padDisplay(cell || '', colWidths[i])).join(' ');
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Box-drawing table ─────────────────────────────────────
|
||||
|
||||
interface BoxTableColumn {
|
||||
align?: 'left' | 'right';
|
||||
header: string | string[];
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface BoxTableRow {
|
||||
[key: string]: string | string[];
|
||||
}
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
return n.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
export function formatCost(n: number): string {
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Strip ANSI escape codes for accurate width calculation
|
||||
function stripAnsi(s: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return s.replaceAll(/\x1B\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the display width of a string in the terminal.
|
||||
* CJK characters and fullwidth symbols occupy 2 columns.
|
||||
*/
|
||||
function displayWidth(s: string): number {
|
||||
const plain = stripAnsi(s);
|
||||
let width = 0;
|
||||
for (const char of plain) {
|
||||
const code = char.codePointAt(0)!;
|
||||
if (
|
||||
(code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
|
||||
(code >= 0x2e80 && code <= 0x303e) || // CJK Radicals, Kangxi, Symbols
|
||||
(code >= 0x3040 && code <= 0x33bf) || // Hiragana, Katakana, Bopomofo, CJK Compat
|
||||
(code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A
|
||||
(code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
|
||||
(code >= 0xa000 && code <= 0xa4cf) || // Yi Syllables/Radicals
|
||||
(code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables
|
||||
(code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
|
||||
(code >= 0xfe30 && code <= 0xfe6f) || // CJK Compatibility Forms
|
||||
(code >= 0xff01 && code <= 0xff60) || // Fullwidth Forms
|
||||
(code >= 0xffe0 && code <= 0xffe6) || // Fullwidth Signs
|
||||
(code >= 0x20000 && code <= 0x2fa1f) // CJK Extension B–F, Compatibility Supplement
|
||||
) {
|
||||
width += 2;
|
||||
} else {
|
||||
width += 1;
|
||||
}
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad a string to the target display width, accounting for CJK double-width characters.
|
||||
*/
|
||||
function padDisplay(s: string, targetWidth: number, align: 'left' | 'right' = 'left'): string {
|
||||
const gap = targetWidth - displayWidth(s);
|
||||
if (gap <= 0) return s;
|
||||
return align === 'right' ? ' '.repeat(gap) + s : s + ' '.repeat(gap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a bordered table with box-drawing characters, similar to ccusage output.
|
||||
* Supports multi-line cells (string[]).
|
||||
*/
|
||||
export function printBoxTable(columns: BoxTableColumn[], rows: BoxTableRow[], title?: string) {
|
||||
// Calculate the display height of each row (max lines across all cells)
|
||||
const rowHeights = rows.map((row) => {
|
||||
let maxLines = 1;
|
||||
for (const col of columns) {
|
||||
const val = row[col.key];
|
||||
if (Array.isArray(val) && val.length > maxLines) maxLines = val.length;
|
||||
}
|
||||
return maxLines;
|
||||
});
|
||||
|
||||
// Calculate column widths: max of header width and all cell widths
|
||||
const colWidths = columns.map((col) => {
|
||||
const headerLines = Array.isArray(col.header) ? col.header : [col.header];
|
||||
let maxW = Math.max(...headerLines.map((h) => displayWidth(h)));
|
||||
for (const row of rows) {
|
||||
const val = row[col.key];
|
||||
const lines = Array.isArray(val) ? val : [val || ''];
|
||||
for (const line of lines) {
|
||||
const w = displayWidth(line);
|
||||
if (w > maxW) maxW = w;
|
||||
}
|
||||
}
|
||||
return maxW;
|
||||
});
|
||||
|
||||
// Box-drawing chars
|
||||
const TL = '┌',
|
||||
TR = '┐',
|
||||
BL = '└',
|
||||
BR = '┘';
|
||||
const H = '─',
|
||||
V = '│';
|
||||
const TJ = '┬',
|
||||
BJ = '┴',
|
||||
LJ = '├',
|
||||
RJ = '┤',
|
||||
CJ = '┼';
|
||||
|
||||
const pad = (s: string, w: number, align: 'left' | 'right' = 'left') => {
|
||||
return padDisplay(s, w, align);
|
||||
};
|
||||
|
||||
const hLine = (left: string, mid: string, right: string) =>
|
||||
left + colWidths.map((w) => H.repeat(w + 2)).join(mid) + right;
|
||||
|
||||
const renderRow = (cells: string[], align?: ('left' | 'right')[]) =>
|
||||
V +
|
||||
cells.map((c, i) => ' ' + pad(c, colWidths[i], align?.[i] || columns[i].align) + ' ').join(V) +
|
||||
V;
|
||||
|
||||
// Title box
|
||||
if (title) {
|
||||
const totalWidth = colWidths.reduce((a, b) => a + b, 0) + (colWidths.length - 1) * 3 + 4;
|
||||
const innerW = totalWidth - 4;
|
||||
const titlePad = Math.max(0, innerW - displayWidth(title));
|
||||
const leftPad = Math.floor(titlePad / 2);
|
||||
const rightPad = titlePad - leftPad;
|
||||
console.log();
|
||||
console.log(' ╭' + '─'.repeat(innerW + 2) + '╮');
|
||||
console.log(' │ ' + ' '.repeat(leftPad) + pc.bold(title) + ' '.repeat(rightPad) + ' │');
|
||||
console.log(' ╰' + '─'.repeat(innerW + 2) + '╯');
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Header
|
||||
const headerHeight = Math.max(
|
||||
...columns.map((c) => (Array.isArray(c.header) ? c.header.length : 1)),
|
||||
);
|
||||
|
||||
console.log(hLine(TL, TJ, TR));
|
||||
for (let line = 0; line < headerHeight; line++) {
|
||||
const cells = columns.map((col) => {
|
||||
const headerLines = Array.isArray(col.header) ? col.header : [col.header];
|
||||
return headerLines[line] || '';
|
||||
});
|
||||
console.log(
|
||||
renderRow(
|
||||
cells,
|
||||
columns.map(() => 'left'),
|
||||
),
|
||||
);
|
||||
}
|
||||
console.log(hLine(LJ, CJ, RJ));
|
||||
|
||||
// Data rows
|
||||
rows.forEach((row, rowIdx) => {
|
||||
const height = rowHeights[rowIdx];
|
||||
for (let line = 0; line < height; line++) {
|
||||
const cells = columns.map((col) => {
|
||||
const val = row[col.key];
|
||||
const lines = Array.isArray(val) ? val : [val || ''];
|
||||
return lines[line] || '';
|
||||
});
|
||||
console.log(renderRow(cells));
|
||||
}
|
||||
if (rowIdx < rows.length - 1) {
|
||||
console.log(hLine(LJ, CJ, RJ));
|
||||
}
|
||||
});
|
||||
|
||||
console.log(hLine(BL, BJ, BR));
|
||||
}
|
||||
|
||||
export function pickFields(obj: Record<string, any>, fields: string[]): Record<string, any> {
|
||||
const result: Record<string, any> = {};
|
||||
for (const f of fields) {
|
||||
@@ -60,6 +258,135 @@ export function outputJson(data: unknown, fields?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Calendar Heatmap ──────────────────────────────────────
|
||||
|
||||
interface CalendarDay {
|
||||
day: string; // YYYY-MM-DD
|
||||
value: number;
|
||||
}
|
||||
|
||||
const HEATMAP_BLOCKS = [' ', '░', '▒', '▓', '█'];
|
||||
const WEEKDAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', ''];
|
||||
|
||||
/**
|
||||
* Render a GitHub-style calendar heatmap for usage data.
|
||||
* Each column is a week, rows are weekdays (Mon-Sun).
|
||||
*/
|
||||
export function printCalendarHeatmap(
|
||||
data: CalendarDay[],
|
||||
options?: { label?: string; title?: string },
|
||||
) {
|
||||
if (data.length === 0) return;
|
||||
|
||||
// Build a value map
|
||||
const valueMap = new Map<string, number>();
|
||||
let maxVal = 0;
|
||||
for (const d of data) {
|
||||
valueMap.set(d.day, d.value);
|
||||
if (d.value > maxVal) maxVal = d.value;
|
||||
}
|
||||
|
||||
// Determine date range - pad to full weeks
|
||||
const sorted = [...data].sort((a, b) => a.day.localeCompare(b.day));
|
||||
const firstDate = new Date(sorted[0].day);
|
||||
const lastDate = new Date(sorted.at(-1).day);
|
||||
|
||||
// Adjust to start on Monday
|
||||
const startDay = firstDate.getDay(); // 0=Sun, 1=Mon, ...
|
||||
const mondayOffset = startDay === 0 ? 6 : startDay - 1;
|
||||
const start = new Date(firstDate);
|
||||
start.setDate(start.getDate() - mondayOffset);
|
||||
|
||||
// Adjust to end on Sunday
|
||||
const endDay = lastDate.getDay();
|
||||
const sundayOffset = endDay === 0 ? 0 : 7 - endDay;
|
||||
const end = new Date(lastDate);
|
||||
end.setDate(end.getDate() + sundayOffset);
|
||||
|
||||
// Build grid: 7 rows (Mon-Sun) x N weeks
|
||||
const weeks: string[][] = [];
|
||||
const current = new Date(start);
|
||||
let weekCol: string[] = [];
|
||||
|
||||
while (current <= end) {
|
||||
const key = current.toISOString().slice(0, 10);
|
||||
const val = valueMap.get(key) || 0;
|
||||
|
||||
// Quantize to block level
|
||||
let level: number;
|
||||
if (val === 0) {
|
||||
level = 0;
|
||||
} else if (maxVal > 0) {
|
||||
level = Math.ceil((val / maxVal) * 4);
|
||||
if (level < 1) level = 1;
|
||||
if (level > 4) level = 4;
|
||||
} else {
|
||||
level = 0;
|
||||
}
|
||||
|
||||
// Color the block
|
||||
const block = HEATMAP_BLOCKS[level];
|
||||
const colored = level > 0 ? pc.green(block) : pc.dim(block);
|
||||
|
||||
weekCol.push(colored);
|
||||
|
||||
if (weekCol.length === 7) {
|
||||
weeks.push(weekCol);
|
||||
weekCol = [];
|
||||
}
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
if (weekCol.length > 0) {
|
||||
while (weekCol.length < 7) weekCol.push(' ');
|
||||
weeks.push(weekCol);
|
||||
}
|
||||
|
||||
// Print title
|
||||
if (options?.title) {
|
||||
console.log();
|
||||
console.log(pc.bold(options.title));
|
||||
}
|
||||
|
||||
// Print month labels on top, aligned with week columns
|
||||
const monthLine: string[] = [];
|
||||
let lastMonth = '';
|
||||
for (let w = 0; w < weeks.length; w++) {
|
||||
const weekStart = new Date(start);
|
||||
weekStart.setDate(weekStart.getDate() + w * 7);
|
||||
const monthStr = weekStart.toLocaleString('en-US', { month: 'short' });
|
||||
if (monthStr !== lastMonth) {
|
||||
monthLine.push(monthStr.padEnd(2));
|
||||
lastMonth = monthStr;
|
||||
} else {
|
||||
monthLine.push(' ');
|
||||
}
|
||||
}
|
||||
console.log(pc.dim(' ' + monthLine.join('')));
|
||||
|
||||
// Print each row (weekday)
|
||||
for (let row = 0; row < 7; row++) {
|
||||
const label = (WEEKDAY_LABELS[row] || '').padEnd(4);
|
||||
const cells = weeks.map((week) => week[row] || ' ').join(' ');
|
||||
console.log(pc.dim(label) + ' ' + cells);
|
||||
}
|
||||
|
||||
// Legend
|
||||
const legend =
|
||||
' ' +
|
||||
pc.dim('Less ') +
|
||||
HEATMAP_BLOCKS.map((b, i) => (i === 0 ? pc.dim(b) : pc.green(b))).join(' ') +
|
||||
pc.dim(' More');
|
||||
console.log();
|
||||
console.log(legend);
|
||||
|
||||
// Label
|
||||
if (options?.label) {
|
||||
console.log(pc.dim(` ${options.label}`));
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
export function confirm(message: string): Promise<boolean> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"@lobechat/device-gateway-client": ["../../packages/device-gateway-client/src"],
|
||||
"@lobechat/local-file-shell": ["../../packages/local-file-shell/src"],
|
||||
"@/*": ["../../src/*"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,8 +4,15 @@ export default defineConfig({
|
||||
banner: { js: '#!/usr/bin/env node' },
|
||||
clean: true,
|
||||
entry: ['src/index.ts'],
|
||||
external: ['@napi-rs/canvas', 'fast-glob', 'diff', 'debug'],
|
||||
format: ['esm'],
|
||||
noExternal: ['@lobechat/device-gateway-client', '@trpc/client', 'superjson'],
|
||||
noExternal: [
|
||||
'@lobechat/device-gateway-client',
|
||||
'@lobechat/local-file-shell',
|
||||
'@lobechat/file-loaders',
|
||||
'@trpc/client',
|
||||
'superjson',
|
||||
],
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
});
|
||||
|
||||
@@ -9,6 +9,14 @@ export default defineConfig({
|
||||
find: '@lobechat/device-gateway-client',
|
||||
replacement: path.resolve(__dirname, '../../packages/device-gateway-client/src/index.ts'),
|
||||
},
|
||||
{
|
||||
find: '@lobechat/local-file-shell',
|
||||
replacement: path.resolve(__dirname, '../../packages/local-file-shell/src/index.ts'),
|
||||
},
|
||||
{
|
||||
find: '@lobechat/file-loaders',
|
||||
replacement: path.resolve(__dirname, '../../packages/file-loaders/src/index.ts'),
|
||||
},
|
||||
],
|
||||
},
|
||||
test: {
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"@lobechat/electron-client-ipc": "workspace:*",
|
||||
"@lobechat/electron-server-ipc": "workspace:*",
|
||||
"@lobechat/file-loaders": "workspace:*",
|
||||
"@lobechat/local-file-shell": "workspace:*",
|
||||
"@lobehub/i18n-cli": "^1.25.1",
|
||||
"@modelcontextprotocol/sdk": "^1.24.3",
|
||||
"@t3-oss/env-core": "^0.13.8",
|
||||
|
||||
@@ -3,4 +3,5 @@ packages:
|
||||
- '../../packages/electron-client-ipc'
|
||||
- '../../packages/file-loaders'
|
||||
- '../../packages/desktop-bridge'
|
||||
- '../../packages/local-file-shell'
|
||||
- '.'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -31,15 +31,20 @@ import {
|
||||
type ShowSaveDialogResult,
|
||||
type WriteLocalFileParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { loadFile, SYSTEM_FILES_TO_IGNORE } from '@lobechat/file-loaders';
|
||||
import { createPatch } from 'diff';
|
||||
import {
|
||||
editLocalFile,
|
||||
listLocalFiles,
|
||||
moveLocalFiles,
|
||||
readLocalFile,
|
||||
renameLocalFile,
|
||||
writeLocalFile,
|
||||
} from '@lobechat/local-file-shell';
|
||||
import { dialog, shell } from 'electron';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
import { type FileResult, type SearchOptions } from '@/modules/fileSearch';
|
||||
import ContentSearchService from '@/services/contentSearchSrv';
|
||||
import FileSearchService from '@/services/fileSearchSrv';
|
||||
import { makeSureDirExist } from '@/utils/file-system';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
@@ -184,9 +189,8 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
const results: LocalReadFileResult[] = [];
|
||||
|
||||
for (const filePath of paths) {
|
||||
// Initialize result object
|
||||
logger.debug('Reading single file:', { filePath });
|
||||
const result = await this.readFile({ path: filePath });
|
||||
const result = await readLocalFile({ path: filePath });
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
@@ -195,284 +199,27 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async readFile({
|
||||
path: filePath,
|
||||
loc,
|
||||
fullContent,
|
||||
}: LocalReadFileParams): Promise<LocalReadFileResult> {
|
||||
const effectiveLoc = fullContent ? undefined : (loc ?? [0, 200]);
|
||||
logger.debug('Starting to read file:', { filePath, fullContent, loc: effectiveLoc });
|
||||
|
||||
try {
|
||||
const fileDocument = await loadFile(filePath);
|
||||
|
||||
const lines = fileDocument.content.split('\n');
|
||||
const totalLineCount = lines.length;
|
||||
const totalCharCount = fileDocument.content.length;
|
||||
|
||||
let content: string;
|
||||
let charCount: number;
|
||||
let lineCount: number;
|
||||
let actualLoc: [number, number];
|
||||
|
||||
if (effectiveLoc === undefined) {
|
||||
// Return full content
|
||||
content = fileDocument.content;
|
||||
charCount = totalCharCount;
|
||||
lineCount = totalLineCount;
|
||||
actualLoc = [0, totalLineCount];
|
||||
} else {
|
||||
// Return specified range
|
||||
const [startLine, endLine] = effectiveLoc;
|
||||
const selectedLines = lines.slice(startLine, endLine);
|
||||
content = selectedLines.join('\n');
|
||||
charCount = content.length;
|
||||
lineCount = selectedLines.length;
|
||||
actualLoc = effectiveLoc;
|
||||
}
|
||||
|
||||
logger.debug('File read successfully:', {
|
||||
filePath,
|
||||
fullContent,
|
||||
selectedLineCount: lineCount,
|
||||
totalCharCount,
|
||||
totalLineCount,
|
||||
});
|
||||
|
||||
const result: LocalReadFileResult = {
|
||||
// Char count for the selected range
|
||||
charCount,
|
||||
// Content for the selected range
|
||||
content,
|
||||
createdTime: fileDocument.createdTime,
|
||||
fileType: fileDocument.fileType,
|
||||
filename: fileDocument.filename,
|
||||
lineCount,
|
||||
loc: actualLoc,
|
||||
// Line count for the selected range
|
||||
modifiedTime: fileDocument.modifiedTime,
|
||||
|
||||
// Total char count of the file
|
||||
totalCharCount,
|
||||
// Total line count of the file
|
||||
totalLineCount,
|
||||
};
|
||||
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
if (stats.isDirectory()) {
|
||||
logger.warn('Attempted to read directory content:', { filePath });
|
||||
result.content = 'This is a directory and cannot be read as plain text.';
|
||||
result.charCount = 0;
|
||||
result.lineCount = 0;
|
||||
// Keep total counts for directory as 0 as well, or decide if they should reflect metadata size
|
||||
result.totalCharCount = 0;
|
||||
result.totalLineCount = 0;
|
||||
}
|
||||
} catch (statError) {
|
||||
logger.error(`Failed to get file status ${filePath}:`, statError);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read file ${filePath}:`, error);
|
||||
const errorMessage = (error as Error).message;
|
||||
return {
|
||||
charCount: 0,
|
||||
content: `Error accessing or processing file: ${errorMessage}`,
|
||||
createdTime: new Date(),
|
||||
fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown',
|
||||
filename: path.basename(filePath),
|
||||
lineCount: 0,
|
||||
loc: [0, 0],
|
||||
modifiedTime: new Date(),
|
||||
totalCharCount: 0, // Add total counts to error result
|
||||
totalLineCount: 0,
|
||||
};
|
||||
}
|
||||
async readFile(params: LocalReadFileParams): Promise<LocalReadFileResult> {
|
||||
logger.debug('Starting to read file:', {
|
||||
filePath: params.path,
|
||||
fullContent: params.fullContent,
|
||||
loc: params.loc,
|
||||
});
|
||||
return readLocalFile(params);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async listLocalFiles({
|
||||
path: dirPath,
|
||||
sortBy = 'modifiedTime',
|
||||
sortOrder = 'desc',
|
||||
limit = 100,
|
||||
}: ListLocalFileParams): Promise<{ files: FileResult[]; totalCount: number }> {
|
||||
logger.debug('Listing directory contents:', { dirPath, limit, sortBy, sortOrder });
|
||||
|
||||
const results: FileResult[] = [];
|
||||
try {
|
||||
const entries = await readdir(dirPath);
|
||||
logger.debug('Directory entries retrieved successfully:', {
|
||||
dirPath,
|
||||
entriesCount: entries.length,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip specific system files based on the ignore list
|
||||
if (SYSTEM_FILES_TO_IGNORE.includes(entry)) {
|
||||
logger.debug('Ignoring system file:', { fileName: entry });
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.join(dirPath, entry);
|
||||
try {
|
||||
const stats = await stat(fullPath);
|
||||
const isDirectory = stats.isDirectory();
|
||||
results.push({
|
||||
createdTime: stats.birthtime,
|
||||
isDirectory,
|
||||
lastAccessTime: stats.atime,
|
||||
modifiedTime: stats.mtime,
|
||||
name: entry,
|
||||
path: fullPath,
|
||||
size: stats.size,
|
||||
type: isDirectory ? 'directory' : path.extname(entry).toLowerCase().replace('.', ''),
|
||||
});
|
||||
} catch (statError) {
|
||||
// Silently ignore files we can't stat (e.g. permissions)
|
||||
logger.error(`Failed to get file status ${fullPath}:`, statError);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort entries based on sortBy and sortOrder
|
||||
results.sort((a, b) => {
|
||||
const comparison =
|
||||
sortBy === 'name'
|
||||
? (a.name || '').localeCompare(b.name || '')
|
||||
: sortBy === 'createdTime'
|
||||
? a.createdTime.getTime() - b.createdTime.getTime()
|
||||
: sortBy === 'size'
|
||||
? a.size - b.size
|
||||
: a.modifiedTime.getTime() - b.modifiedTime.getTime();
|
||||
|
||||
return sortOrder === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
|
||||
const totalCount = results.length;
|
||||
|
||||
// Apply limit
|
||||
const limitedResults = results.slice(0, limit);
|
||||
|
||||
logger.debug('Directory listing successful', {
|
||||
dirPath,
|
||||
resultCount: limitedResults.length,
|
||||
totalCount,
|
||||
});
|
||||
return { files: limitedResults, totalCount };
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list directory ${dirPath}:`, error);
|
||||
// Rethrow or return an empty array/error object depending on desired behavior
|
||||
// For now, returning empty result on error listing directory itself
|
||||
return { files: [], totalCount: 0 };
|
||||
}
|
||||
async listLocalFiles(
|
||||
params: ListLocalFileParams,
|
||||
): Promise<{ files: FileResult[]; totalCount: number }> {
|
||||
logger.debug('Listing directory contents:', params);
|
||||
return listLocalFiles(params) as any;
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async handleMoveFiles({ items }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
|
||||
logger.debug('Starting batch file move:', { itemsCount: items?.length });
|
||||
|
||||
const results: LocalMoveFilesResultItem[] = [];
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
logger.warn('moveLocalFiles called with empty parameters');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Process each move request
|
||||
for (const item of items) {
|
||||
const { oldPath: sourcePath, newPath } = item;
|
||||
const logPrefix = `[Moving file ${sourcePath} -> ${newPath}]`;
|
||||
logger.debug(`${logPrefix} Starting process`);
|
||||
|
||||
const resultItem: LocalMoveFilesResultItem = {
|
||||
newPath: undefined,
|
||||
sourcePath,
|
||||
success: false,
|
||||
};
|
||||
|
||||
// Basic validation
|
||||
if (!sourcePath || !newPath) {
|
||||
logger.error(`${logPrefix} Parameter validation failed: source or target path is empty`);
|
||||
resultItem.error = 'Both oldPath and newPath are required for each item.';
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if source exists
|
||||
try {
|
||||
await access(sourcePath, constants.F_OK);
|
||||
logger.debug(`${logPrefix} Source file exists`);
|
||||
} catch (accessError: any) {
|
||||
if (accessError.code === 'ENOENT') {
|
||||
logger.error(`${logPrefix} Source file does not exist`);
|
||||
throw new Error(`Source path not found: ${sourcePath}`, { cause: accessError });
|
||||
} else {
|
||||
logger.error(`${logPrefix} Permission error accessing source file:`, accessError);
|
||||
throw new Error(
|
||||
`Permission denied accessing source path: ${sourcePath}. ${accessError.message}`,
|
||||
{ cause: accessError },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if target path is the same as source path
|
||||
if (path.normalize(sourcePath) === path.normalize(newPath)) {
|
||||
logger.info(`${logPrefix} Source and target paths are identical, skipping move`);
|
||||
resultItem.success = true;
|
||||
resultItem.newPath = newPath; // Report target path even if not moved
|
||||
results.push(resultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
// LBYL: Ensure target directory exists
|
||||
const targetDir = path.dirname(newPath);
|
||||
makeSureDirExist(targetDir);
|
||||
logger.debug(`${logPrefix} Ensured target directory exists: ${targetDir}`);
|
||||
|
||||
// Execute move (rename)
|
||||
await rename(sourcePath, newPath);
|
||||
resultItem.success = true;
|
||||
resultItem.newPath = newPath;
|
||||
logger.info(`${logPrefix} Move successful`);
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Move failed:`, error);
|
||||
// Use similar error handling logic as handleMoveFile
|
||||
let errorMessage = (error as Error).message;
|
||||
if ((error as any).code === 'ENOENT')
|
||||
errorMessage = `Source path not found: ${sourcePath}.`;
|
||||
else if ((error as any).code === 'EPERM' || (error as any).code === 'EACCES')
|
||||
errorMessage = `Permission denied to move the item at ${sourcePath}. Check file/folder permissions.`;
|
||||
else if ((error as any).code === 'EBUSY')
|
||||
errorMessage = `The file or directory at ${sourcePath} or ${newPath} is busy or locked by another process.`;
|
||||
else if ((error as any).code === 'EXDEV')
|
||||
errorMessage = `Cannot move across different file systems or drives. Source: ${sourcePath}, Target: ${newPath}.`;
|
||||
else if ((error as any).code === 'EISDIR')
|
||||
errorMessage = `Cannot overwrite a directory with a file, or vice versa. Source: ${sourcePath}, Target: ${newPath}.`;
|
||||
else if ((error as any).code === 'ENOTEMPTY')
|
||||
errorMessage = `The target directory ${newPath} is not empty (relevant on some systems if target exists and is a directory).`;
|
||||
else if ((error as any).code === 'EEXIST')
|
||||
errorMessage = `An item already exists at the target path: ${newPath}.`;
|
||||
// Keep more specific errors from access or directory checks
|
||||
else if (
|
||||
!errorMessage.startsWith('Source path not found') &&
|
||||
!errorMessage.startsWith('Permission denied accessing source path') &&
|
||||
!errorMessage.includes('Target directory')
|
||||
) {
|
||||
// Keep the original error message if none of the specific codes match
|
||||
}
|
||||
resultItem.error = errorMessage;
|
||||
}
|
||||
results.push(resultItem);
|
||||
}
|
||||
|
||||
logger.debug('Batch file move completed', {
|
||||
successCount: results.filter((r) => r.success).length,
|
||||
totalCount: results.length,
|
||||
});
|
||||
return results;
|
||||
return moveLocalFiles({ items });
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
@@ -483,121 +230,14 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
newName: string;
|
||||
path: string;
|
||||
}): Promise<RenameLocalFileResult> {
|
||||
const logPrefix = `[Renaming ${currentPath} -> ${newName}]`;
|
||||
logger.debug(`${logPrefix} Starting rename request`);
|
||||
|
||||
// Basic validation (can also be done in frontend action)
|
||||
if (!currentPath || !newName) {
|
||||
logger.error(`${logPrefix} Parameter validation failed: path or new name is empty`);
|
||||
return { error: 'Both path and newName are required.', newPath: '', success: false };
|
||||
}
|
||||
// Prevent path traversal or using invalid characters/names
|
||||
if (
|
||||
newName.includes('/') ||
|
||||
newName.includes('\\') ||
|
||||
newName === '.' ||
|
||||
newName === '..' ||
|
||||
/["*/:<>?\\|]/.test(newName) // Check for typical invalid filename characters
|
||||
) {
|
||||
logger.error(`${logPrefix} New filename contains illegal characters: ${newName}`);
|
||||
return {
|
||||
error:
|
||||
'Invalid new name. It cannot contain path separators (/, \\), be "." or "..", or include characters like < > : " / \\ | ? *.',
|
||||
newPath: '',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
let newPath: string;
|
||||
try {
|
||||
const dir = path.dirname(currentPath);
|
||||
newPath = path.join(dir, newName);
|
||||
logger.debug(`${logPrefix} Calculated new path: ${newPath}`);
|
||||
|
||||
// Check if paths are identical after calculation
|
||||
if (path.normalize(currentPath) === path.normalize(newPath)) {
|
||||
logger.info(
|
||||
`${logPrefix} Source path and calculated target path are identical, skipping rename`,
|
||||
);
|
||||
// Consider success as no change is needed, but maybe inform the user?
|
||||
// Return success for now.
|
||||
return { newPath, success: true };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to calculate new path:`, error);
|
||||
return {
|
||||
error: `Internal error calculating the new path: ${(error as Error).message}`,
|
||||
newPath: '',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Perform the rename operation using rename directly
|
||||
try {
|
||||
await rename(currentPath, newPath);
|
||||
logger.info(`${logPrefix} Rename successful: ${currentPath} -> ${newPath}`);
|
||||
// Optionally return the newPath if frontend needs it
|
||||
// return { success: true, newPath: newPath };
|
||||
return { newPath, success: true };
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Rename failed:`, error);
|
||||
let errorMessage = (error as Error).message;
|
||||
// Provide more specific error messages based on common codes
|
||||
if ((error as any).code === 'ENOENT') {
|
||||
errorMessage = `File or directory not found at the original path: ${currentPath}.`;
|
||||
} else if ((error as any).code === 'EPERM' || (error as any).code === 'EACCES') {
|
||||
errorMessage = `Permission denied to rename the item at ${currentPath}. Check file/folder permissions.`;
|
||||
} else if ((error as any).code === 'EBUSY') {
|
||||
errorMessage = `The file or directory at ${currentPath} or ${newPath} is busy or locked by another process.`;
|
||||
} else if ((error as any).code === 'EISDIR' || (error as any).code === 'ENOTDIR') {
|
||||
errorMessage = `Cannot rename - conflict between file and directory. Source: ${currentPath}, Target: ${newPath}.`;
|
||||
} else if ((error as any).code === 'EEXIST') {
|
||||
// Target already exists
|
||||
errorMessage = `Cannot rename: an item with the name '${newName}' already exists at this location.`;
|
||||
}
|
||||
// Add more specific checks as needed
|
||||
return { error: errorMessage, newPath: '', success: false };
|
||||
}
|
||||
logger.debug(`Renaming ${currentPath} -> ${newName}`);
|
||||
return renameLocalFile({ newName, path: currentPath });
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async handleWriteFile({ path: filePath, content }: WriteLocalFileParams) {
|
||||
const logPrefix = `[Writing file ${filePath}]`;
|
||||
logger.debug(`${logPrefix} Starting to write file`, { contentLength: content?.length });
|
||||
|
||||
// Validate parameters
|
||||
if (!filePath) {
|
||||
logger.error(`${logPrefix} Parameter validation failed: path is empty`);
|
||||
return { error: 'Path cannot be empty', success: false };
|
||||
}
|
||||
|
||||
if (content === undefined) {
|
||||
logger.error(`${logPrefix} Parameter validation failed: content is empty`);
|
||||
return { error: 'Content cannot be empty', success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure target directory exists (use async to avoid blocking main thread)
|
||||
const dirname = path.dirname(filePath);
|
||||
logger.debug(`${logPrefix} Creating directory: ${dirname}`);
|
||||
await mkdir(dirname, { recursive: true });
|
||||
|
||||
// Write file content
|
||||
logger.debug(`${logPrefix} Starting to write content to file`);
|
||||
await writeFile(filePath, content, 'utf8');
|
||||
logger.info(`${logPrefix} File written successfully`, {
|
||||
path: filePath,
|
||||
size: content.length,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to write file:`, error);
|
||||
return {
|
||||
error: `Failed to write file: ${(error as Error).message}`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
logger.debug(`Writing file ${filePath}`, { contentLength: content?.length });
|
||||
return writeLocalFile({ content, path: filePath });
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
@@ -746,92 +386,8 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
// ==================== File Editing ====================
|
||||
|
||||
@IpcMethod()
|
||||
async handleEditFile({
|
||||
file_path: filePath,
|
||||
new_string,
|
||||
old_string,
|
||||
replace_all = false,
|
||||
}: EditLocalFileParams): Promise<EditLocalFileResult> {
|
||||
const logPrefix = `[editFile: ${filePath}]`;
|
||||
logger.debug(`${logPrefix} Starting file edit`, { replace_all });
|
||||
|
||||
try {
|
||||
// Read file content
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
|
||||
// Check if old_string exists
|
||||
if (!content.includes(old_string)) {
|
||||
logger.error(`${logPrefix} Old string not found in file`);
|
||||
return {
|
||||
error: 'The specified old_string was not found in the file',
|
||||
replacements: 0,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Perform replacement
|
||||
let newContent: string;
|
||||
let replacements: number;
|
||||
|
||||
if (replace_all) {
|
||||
const regex = new RegExp(old_string.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'), 'g');
|
||||
const matches = content.match(regex);
|
||||
replacements = matches ? matches.length : 0;
|
||||
newContent = content.replaceAll(old_string, new_string);
|
||||
} else {
|
||||
// Replace only first occurrence
|
||||
const index = content.indexOf(old_string);
|
||||
if (index === -1) {
|
||||
return {
|
||||
error: 'Old string not found',
|
||||
replacements: 0,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
newContent =
|
||||
content.slice(0, index) + new_string + content.slice(index + old_string.length);
|
||||
replacements = 1;
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
await writeFile(filePath, newContent, 'utf8');
|
||||
|
||||
// Generate diff for UI display
|
||||
const patch = createPatch(filePath, content, newContent, '', '');
|
||||
const diffText = `diff --git a${filePath} b${filePath}\n${patch}`;
|
||||
|
||||
// Calculate lines added and deleted from patch
|
||||
const patchLines = patch.split('\n');
|
||||
let linesAdded = 0;
|
||||
let linesDeleted = 0;
|
||||
|
||||
for (const line of patchLines) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
linesAdded++;
|
||||
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
linesDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${logPrefix} File edited successfully`, {
|
||||
linesAdded,
|
||||
linesDeleted,
|
||||
replacements,
|
||||
});
|
||||
return {
|
||||
diffText,
|
||||
linesAdded,
|
||||
linesDeleted,
|
||||
replacements,
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Edit failed:`, error);
|
||||
return {
|
||||
error: (error as Error).message,
|
||||
replacements: 0,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
async handleEditFile(params: EditLocalFileParams): Promise<EditLocalFileResult> {
|
||||
logger.debug(`Editing file ${params.file_path}`, { replace_all: params.replace_all });
|
||||
return editLocalFile(params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
GetCommandOutputParams,
|
||||
GetCommandOutputResult,
|
||||
@@ -10,6 +6,7 @@ import type {
|
||||
RunCommandParams,
|
||||
RunCommandResult,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { runCommand, ShellProcessManager } from '@lobechat/local-file-shell';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
@@ -17,256 +14,23 @@ import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:ShellCommandCtr');
|
||||
|
||||
// Maximum output length to prevent context explosion
|
||||
const MAX_OUTPUT_LENGTH = 80_000;
|
||||
|
||||
/**
|
||||
* Strip ANSI escape codes from terminal output
|
||||
*/
|
||||
// eslint-disable-next-line no-control-regex, regexp/no-obscure-range -- ANSI escape sequences use these ranges
|
||||
const ANSI_REGEX = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
||||
const stripAnsi = (str: string): string => str.replaceAll(ANSI_REGEX, '');
|
||||
|
||||
/**
|
||||
* Truncate string to max length with ellipsis indicator
|
||||
*/
|
||||
const truncateOutput = (str: string, maxLength: number = MAX_OUTPUT_LENGTH): string => {
|
||||
const cleaned = stripAnsi(str);
|
||||
if (cleaned.length <= maxLength) return cleaned;
|
||||
return (
|
||||
cleaned.slice(0, maxLength) +
|
||||
'\n... [truncated, ' +
|
||||
(cleaned.length - maxLength) +
|
||||
' more characters]'
|
||||
);
|
||||
};
|
||||
|
||||
interface ShellProcess {
|
||||
lastReadStderr: number;
|
||||
lastReadStdout: number;
|
||||
process: ChildProcess;
|
||||
stderr: string[];
|
||||
stdout: string[];
|
||||
}
|
||||
const processManager = new ShellProcessManager();
|
||||
|
||||
export default class ShellCommandCtr extends ControllerModule {
|
||||
static override readonly groupName = 'shellCommand';
|
||||
// Shell process management
|
||||
private shellProcesses = new Map<string, ShellProcess>();
|
||||
|
||||
@IpcMethod()
|
||||
async handleRunCommand({
|
||||
command,
|
||||
cwd,
|
||||
description,
|
||||
run_in_background,
|
||||
timeout = 120_000,
|
||||
}: RunCommandParams): Promise<RunCommandResult> {
|
||||
const logPrefix = `[runCommand: ${description || command.slice(0, 50)}]`;
|
||||
logger.debug(`${logPrefix} Starting command execution`, {
|
||||
background: run_in_background,
|
||||
timeout,
|
||||
});
|
||||
|
||||
// Validate timeout
|
||||
const effectiveTimeout = Math.min(Math.max(timeout, 1000), 600_000);
|
||||
|
||||
// Cross-platform shell selection
|
||||
const shellConfig =
|
||||
process.platform === 'win32'
|
||||
? { args: ['/c', command], cmd: 'cmd.exe' }
|
||||
: { args: ['-c', command], cmd: '/bin/sh' };
|
||||
|
||||
try {
|
||||
if (run_in_background) {
|
||||
// Background execution
|
||||
const shellId = randomUUID();
|
||||
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
shell: false,
|
||||
});
|
||||
|
||||
const shellProcess: ShellProcess = {
|
||||
lastReadStderr: 0,
|
||||
lastReadStdout: 0,
|
||||
process: childProcess,
|
||||
stderr: [],
|
||||
stdout: [],
|
||||
};
|
||||
|
||||
// Capture output
|
||||
childProcess.stdout?.on('data', (data) => {
|
||||
shellProcess.stdout.push(data.toString());
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data) => {
|
||||
shellProcess.stderr.push(data.toString());
|
||||
});
|
||||
|
||||
childProcess.on('exit', (code) => {
|
||||
logger.debug(`${logPrefix} Background process exited`, { code, shellId });
|
||||
});
|
||||
|
||||
this.shellProcesses.set(shellId, shellProcess);
|
||||
|
||||
logger.info(`${logPrefix} Started background execution`, { shellId });
|
||||
return {
|
||||
shell_id: shellId,
|
||||
success: true,
|
||||
};
|
||||
} else {
|
||||
// Synchronous execution with timeout
|
||||
return new Promise((resolve) => {
|
||||
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
shell: false,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let killed = false;
|
||||
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
killed = true;
|
||||
childProcess.kill();
|
||||
resolve({
|
||||
error: `Command timed out after ${effectiveTimeout}ms`,
|
||||
stderr: truncateOutput(stderr),
|
||||
stdout: truncateOutput(stdout),
|
||||
success: false,
|
||||
});
|
||||
}, effectiveTimeout);
|
||||
|
||||
childProcess.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on('exit', (code) => {
|
||||
if (!killed) {
|
||||
clearTimeout(timeoutHandle);
|
||||
const success = code === 0;
|
||||
logger.info(`${logPrefix} Command completed`, { code, success });
|
||||
resolve({
|
||||
exit_code: code || 0,
|
||||
output: truncateOutput(stdout + stderr),
|
||||
stderr: truncateOutput(stderr),
|
||||
stdout: truncateOutput(stdout),
|
||||
success,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('error', (error) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
logger.error(`${logPrefix} Command failed:`, error);
|
||||
resolve({
|
||||
error: error.message,
|
||||
stderr: truncateOutput(stderr),
|
||||
stdout: truncateOutput(stdout),
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to execute command:`, error);
|
||||
return {
|
||||
error: (error as Error).message,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
async handleRunCommand(params: RunCommandParams): Promise<RunCommandResult> {
|
||||
return runCommand(params, { logger, processManager });
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async handleGetCommandOutput({
|
||||
filter,
|
||||
shell_id,
|
||||
}: GetCommandOutputParams): Promise<GetCommandOutputResult> {
|
||||
const logPrefix = `[getCommandOutput: ${shell_id}]`;
|
||||
logger.debug(`${logPrefix} Retrieving output`);
|
||||
|
||||
const shellProcess = this.shellProcesses.get(shell_id);
|
||||
if (!shellProcess) {
|
||||
logger.error(`${logPrefix} Shell process not found`);
|
||||
return {
|
||||
error: `Shell ID ${shell_id} not found`,
|
||||
output: '',
|
||||
running: false,
|
||||
stderr: '',
|
||||
stdout: '',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { lastReadStderr, lastReadStdout, process: childProcess, stderr, stdout } = shellProcess;
|
||||
|
||||
// Get new output since last read
|
||||
const newStdout = stdout.slice(lastReadStdout).join('');
|
||||
const newStderr = stderr.slice(lastReadStderr).join('');
|
||||
let output = newStdout + newStderr;
|
||||
|
||||
// Apply filter if provided
|
||||
if (filter) {
|
||||
try {
|
||||
const regex = new RegExp(filter, 'gm');
|
||||
const lines = output.split('\n');
|
||||
output = lines.filter((line) => regex.test(line)).join('\n');
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Invalid filter regex:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last read positions separately
|
||||
shellProcess.lastReadStdout = stdout.length;
|
||||
shellProcess.lastReadStderr = stderr.length;
|
||||
|
||||
const running = childProcess.exitCode === null;
|
||||
|
||||
logger.debug(`${logPrefix} Output retrieved`, {
|
||||
outputLength: output.length,
|
||||
running,
|
||||
});
|
||||
|
||||
return {
|
||||
output: truncateOutput(output),
|
||||
running,
|
||||
stderr: truncateOutput(newStderr),
|
||||
stdout: truncateOutput(newStdout),
|
||||
success: true,
|
||||
};
|
||||
async handleGetCommandOutput(params: GetCommandOutputParams): Promise<GetCommandOutputResult> {
|
||||
return processManager.getOutput(params);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async handleKillCommand({ shell_id }: KillCommandParams): Promise<KillCommandResult> {
|
||||
const logPrefix = `[killCommand: ${shell_id}]`;
|
||||
logger.debug(`${logPrefix} Attempting to kill shell`);
|
||||
|
||||
const shellProcess = this.shellProcesses.get(shell_id);
|
||||
if (!shellProcess) {
|
||||
logger.error(`${logPrefix} Shell process not found`);
|
||||
return {
|
||||
error: `Shell ID ${shell_id} not found`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
shellProcess.process.kill();
|
||||
this.shellProcesses.delete(shell_id);
|
||||
logger.info(`${logPrefix} Shell killed successfully`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to kill shell:`, error);
|
||||
return {
|
||||
error: (error as Error).message,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
return processManager.kill(shell_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ vi.mock('electron', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
@@ -24,609 +23,107 @@ vi.mock('@/utils/logger', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock child_process
|
||||
// Mock child_process for the shared package
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock crypto
|
||||
vi.mock('node:crypto', () => ({
|
||||
randomUUID: vi.fn(() => 'test-uuid-123'),
|
||||
}));
|
||||
|
||||
const mockApp = {} as unknown as App;
|
||||
|
||||
describe('ShellCommandCtr', () => {
|
||||
let shellCommandCtr: ShellCommandCtr;
|
||||
describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
let ctr: ShellCommandCtr;
|
||||
let mockSpawn: any;
|
||||
let mockChildProcess: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Import mocks
|
||||
const childProcessModule = await import('node:child_process');
|
||||
mockSpawn = vi.mocked(childProcessModule.spawn);
|
||||
|
||||
// Create mock child process
|
||||
mockChildProcess = {
|
||||
stdout: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
stderr: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
exitCode: null,
|
||||
};
|
||||
|
||||
mockSpawn.mockReturnValue(mockChildProcess);
|
||||
|
||||
shellCommandCtr = new ShellCommandCtr(mockApp);
|
||||
ctr = new ShellCommandCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('handleRunCommand', () => {
|
||||
describe('synchronous mode', () => {
|
||||
it('should execute command successfully', async () => {
|
||||
let exitCallback: (code: number) => void;
|
||||
let stdoutCallback: (data: Buffer) => void;
|
||||
it('should delegate handleRunCommand to shared runCommand', async () => {
|
||||
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') setTimeout(() => callback(0), 10);
|
||||
return mockChildProcess;
|
||||
});
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') setTimeout(() => callback(Buffer.from('output\n')), 5);
|
||||
return mockChildProcess.stdout;
|
||||
});
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') {
|
||||
exitCallback = callback;
|
||||
// Simulate successful exit
|
||||
setTimeout(() => exitCallback(0), 10);
|
||||
}
|
||||
return mockChildProcess;
|
||||
});
|
||||
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
stdoutCallback = callback;
|
||||
// Simulate output
|
||||
setTimeout(() => stdoutCallback(Buffer.from('test output\n')), 5);
|
||||
}
|
||||
return mockChildProcess.stdout;
|
||||
});
|
||||
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
const result = await shellCommandCtr.handleRunCommand({
|
||||
command: 'echo "test"',
|
||||
description: 'test command',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toBe('test output\n');
|
||||
expect(result.exit_code).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle command timeout', async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
const result = await shellCommandCtr.handleRunCommand({
|
||||
command: 'sleep 10',
|
||||
description: 'long running command',
|
||||
timeout: 100,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('timed out');
|
||||
expect(mockChildProcess.kill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle command execution error', async () => {
|
||||
let errorCallback: (error: Error) => void;
|
||||
|
||||
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'error') {
|
||||
errorCallback = callback;
|
||||
setTimeout(() => errorCallback(new Error('Command not found')), 10);
|
||||
}
|
||||
return mockChildProcess;
|
||||
});
|
||||
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
const result = await shellCommandCtr.handleRunCommand({
|
||||
command: 'invalid-command',
|
||||
description: 'invalid command',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Command not found');
|
||||
});
|
||||
|
||||
it('should handle non-zero exit code', async () => {
|
||||
let exitCallback: (code: number) => void;
|
||||
|
||||
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') {
|
||||
exitCallback = callback;
|
||||
setTimeout(() => exitCallback(1), 10);
|
||||
}
|
||||
return mockChildProcess;
|
||||
});
|
||||
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
const result = await shellCommandCtr.handleRunCommand({
|
||||
command: 'exit 1',
|
||||
description: 'failing command',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.exit_code).toBe(1);
|
||||
});
|
||||
|
||||
it('should capture stderr output', async () => {
|
||||
let exitCallback: (code: number) => void;
|
||||
let stderrCallback: (data: Buffer) => void;
|
||||
|
||||
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') {
|
||||
exitCallback = callback;
|
||||
setTimeout(() => exitCallback(1), 10);
|
||||
}
|
||||
return mockChildProcess;
|
||||
});
|
||||
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
stderrCallback = callback;
|
||||
setTimeout(() => stderrCallback(Buffer.from('error message\n')), 5);
|
||||
}
|
||||
return mockChildProcess.stderr;
|
||||
});
|
||||
|
||||
const result = await shellCommandCtr.handleRunCommand({
|
||||
command: 'command-with-error',
|
||||
description: 'command with stderr',
|
||||
});
|
||||
|
||||
expect(result.stderr).toBe('error message\n');
|
||||
});
|
||||
|
||||
it('should strip ANSI escape codes from output', async () => {
|
||||
let exitCallback: (code: number) => void;
|
||||
let stdoutCallback: (data: Buffer) => void;
|
||||
let stderrCallback: (data: Buffer) => void;
|
||||
|
||||
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') {
|
||||
exitCallback = callback;
|
||||
setTimeout(() => exitCallback(0), 10);
|
||||
}
|
||||
return mockChildProcess;
|
||||
});
|
||||
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
stdoutCallback = callback;
|
||||
// Simulate output with ANSI color codes
|
||||
setTimeout(
|
||||
() =>
|
||||
stdoutCallback(
|
||||
Buffer.from(
|
||||
'\x1B[38;5;250m███████╗\x1B[0m\n\x1B[1;32mSuccess\x1B[0m\n\x1B[31mError\x1B[0m',
|
||||
),
|
||||
),
|
||||
5,
|
||||
);
|
||||
}
|
||||
return mockChildProcess.stdout;
|
||||
});
|
||||
|
||||
mockChildProcess.stderr.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
stderrCallback = callback;
|
||||
setTimeout(
|
||||
() => stderrCallback(Buffer.from('\x1B[33mwarning:\x1B[0m something happened')),
|
||||
5,
|
||||
);
|
||||
}
|
||||
return mockChildProcess.stderr;
|
||||
});
|
||||
|
||||
const result = await shellCommandCtr.handleRunCommand({
|
||||
command: 'npx skills find react',
|
||||
description: 'search skills',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// ANSI codes should be stripped
|
||||
expect(result.stdout).not.toContain('\x1B[');
|
||||
expect(result.stdout).toContain('███████╗');
|
||||
expect(result.stdout).toContain('Success');
|
||||
expect(result.stdout).toContain('Error');
|
||||
expect(result.stderr).not.toContain('\x1B[');
|
||||
expect(result.stderr).toContain('warning: something happened');
|
||||
});
|
||||
|
||||
it('should truncate long output to prevent context explosion', async () => {
|
||||
let exitCallback: (code: number) => void;
|
||||
let stdoutCallback: (data: Buffer) => void;
|
||||
|
||||
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') {
|
||||
exitCallback = callback;
|
||||
setTimeout(() => exitCallback(0), 10);
|
||||
}
|
||||
return mockChildProcess;
|
||||
});
|
||||
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
stdoutCallback = callback;
|
||||
// Simulate very long output (100k characters, exceeding 80k MAX_OUTPUT_LENGTH)
|
||||
const longOutput = 'x'.repeat(100_000);
|
||||
setTimeout(() => stdoutCallback(Buffer.from(longOutput)), 5);
|
||||
}
|
||||
return mockChildProcess.stdout;
|
||||
});
|
||||
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
const result = await shellCommandCtr.handleRunCommand({
|
||||
command: 'command-with-long-output',
|
||||
description: 'long output command',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Output should be truncated to 80k + truncation message
|
||||
expect(result.stdout!.length).toBeLessThan(100_000);
|
||||
expect(result.stdout).toContain('truncated');
|
||||
expect(result.stdout).toContain('more characters');
|
||||
});
|
||||
|
||||
it('should enforce timeout limits', async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
// Test minimum timeout
|
||||
const minResult = await shellCommandCtr.handleRunCommand({
|
||||
command: 'sleep 5',
|
||||
timeout: 500, // Below 1000ms minimum
|
||||
});
|
||||
|
||||
expect(minResult.success).toBe(false);
|
||||
expect(minResult.error).toContain('1000ms'); // Should use 1000ms minimum
|
||||
});
|
||||
const result = await ctr.handleRunCommand({
|
||||
command: 'echo test',
|
||||
description: 'test',
|
||||
});
|
||||
|
||||
describe('background mode', () => {
|
||||
it('should start command in background', async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
const result = await shellCommandCtr.handleRunCommand({
|
||||
command: 'long-running-task',
|
||||
description: 'background task',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.shell_id).toBe('test-uuid-123');
|
||||
});
|
||||
|
||||
it('should use correct shell on Windows', async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
await shellCommandCtr.handleRunCommand({
|
||||
command: 'dir',
|
||||
description: 'windows command',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('cmd.exe', ['/c', 'dir'], expect.any(Object));
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
});
|
||||
|
||||
it('should use correct shell on Unix', async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
await shellCommandCtr.handleRunCommand({
|
||||
command: 'ls',
|
||||
description: 'unix command',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('/bin/sh', ['-c', 'ls'], expect.any(Object));
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
});
|
||||
|
||||
it('should pass cwd to spawn options when provided', async () => {
|
||||
let exitCallback: (code: number) => void;
|
||||
|
||||
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') {
|
||||
exitCallback = callback;
|
||||
setTimeout(() => exitCallback(0), 10);
|
||||
}
|
||||
return mockChildProcess;
|
||||
});
|
||||
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
await shellCommandCtr.handleRunCommand({
|
||||
command: 'pwd',
|
||||
cwd: '/tmp/skill-runtime',
|
||||
description: 'run from cwd',
|
||||
});
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'/bin/sh',
|
||||
['-c', 'pwd'],
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/skill-runtime',
|
||||
env: process.env,
|
||||
shell: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('output');
|
||||
});
|
||||
|
||||
describe('handleGetCommandOutput', () => {
|
||||
beforeEach(async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
// Simulate some output
|
||||
setTimeout(() => callback(Buffer.from('line 1\n')), 5);
|
||||
setTimeout(() => callback(Buffer.from('line 2\n')), 10);
|
||||
}
|
||||
return mockChildProcess.stdout;
|
||||
});
|
||||
mockChildProcess.stderr.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
setTimeout(() => callback(Buffer.from('error line\n')), 7);
|
||||
}
|
||||
return mockChildProcess.stderr;
|
||||
});
|
||||
it('should delegate handleGetCommandOutput to processManager', async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') setTimeout(() => callback(Buffer.from('bg output\n')), 5);
|
||||
return mockChildProcess.stdout;
|
||||
});
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
// Start a background process first
|
||||
await shellCommandCtr.handleRunCommand({
|
||||
command: 'test-command',
|
||||
run_in_background: true,
|
||||
});
|
||||
await ctr.handleRunCommand({
|
||||
command: 'test',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
it('should retrieve command output', async () => {
|
||||
// Wait for output to be captured
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const result = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('line 1');
|
||||
expect(result.stderr).toContain('error line');
|
||||
const result = await ctr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
it('should return error for non-existent shell_id', async () => {
|
||||
const result = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'non-existent-id',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should filter output with regex', async () => {
|
||||
// Wait for output to be captured
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
const result = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
filter: 'line 1',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('line 1');
|
||||
expect(result.output).not.toContain('line 2');
|
||||
});
|
||||
|
||||
it('should only return new output since last read', async () => {
|
||||
// Wait for initial output
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
// First read
|
||||
const firstResult = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(firstResult.stdout).toContain('line 1');
|
||||
|
||||
// Second read should return empty (no new output)
|
||||
const secondResult = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(secondResult.stdout).toBe('');
|
||||
expect(secondResult.stderr).toBe('');
|
||||
});
|
||||
|
||||
it('should handle invalid regex filter gracefully', async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
const result = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
filter: '[invalid(regex',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should return unfiltered output when filter is invalid
|
||||
});
|
||||
|
||||
it('should report running status correctly', async () => {
|
||||
mockChildProcess.exitCode = null;
|
||||
|
||||
const runningResult = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(runningResult.running).toBe(true);
|
||||
|
||||
// Simulate process exit
|
||||
mockChildProcess.exitCode = 0;
|
||||
|
||||
const exitedResult = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(exitedResult.running).toBe(false);
|
||||
});
|
||||
|
||||
it('should track stdout and stderr offsets separately when streaming output', async () => {
|
||||
// Create a new background process with manual control over stdout/stderr
|
||||
let stdoutCallback: (data: Buffer) => void;
|
||||
let stderrCallback: (data: Buffer) => void;
|
||||
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
stdoutCallback = callback;
|
||||
}
|
||||
return mockChildProcess.stdout;
|
||||
});
|
||||
|
||||
mockChildProcess.stderr.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
stderrCallback = callback;
|
||||
}
|
||||
return mockChildProcess.stderr;
|
||||
});
|
||||
|
||||
// Start a new background process
|
||||
await shellCommandCtr.handleRunCommand({
|
||||
command: 'test-interleaved',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
// Simulate stderr output first
|
||||
stderrCallback(Buffer.from('error 1\n'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
// First read - should get stderr
|
||||
const firstRead = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
expect(firstRead.stderr).toBe('error 1\n');
|
||||
expect(firstRead.stdout).toBe('');
|
||||
|
||||
// Simulate stdout output after stderr
|
||||
stdoutCallback(Buffer.from('output 1\n'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
// Second read - should get stdout without losing data
|
||||
const secondRead = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
expect(secondRead.stdout).toBe('output 1\n');
|
||||
expect(secondRead.stderr).toBe('');
|
||||
|
||||
// Simulate more stderr
|
||||
stderrCallback(Buffer.from('error 2\n'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
// Third read - should get new stderr
|
||||
const thirdRead = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
expect(thirdRead.stderr).toBe('error 2\n');
|
||||
expect(thirdRead.stdout).toBe('');
|
||||
|
||||
// Simulate more stdout
|
||||
stdoutCallback(Buffer.from('output 2\n'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
// Fourth read - should get new stdout
|
||||
const fourthRead = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
expect(fourthRead.stdout).toBe('output 2\n');
|
||||
expect(fourthRead.stderr).toBe('');
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stdout).toContain('bg output');
|
||||
});
|
||||
|
||||
describe('handleKillCommand', () => {
|
||||
beforeEach(async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
it('should delegate handleKillCommand to processManager', async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
// Start a background process
|
||||
await shellCommandCtr.handleRunCommand({
|
||||
command: 'test-command',
|
||||
run_in_background: true,
|
||||
});
|
||||
await ctr.handleRunCommand({
|
||||
command: 'test',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
it('should kill command successfully', async () => {
|
||||
const result = await shellCommandCtr.handleKillCommand({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockChildProcess.kill).toHaveBeenCalled();
|
||||
const result = await ctr.handleKillCommand({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
it('should return error for non-existent shell_id', async () => {
|
||||
const result = await shellCommandCtr.handleKillCommand({
|
||||
shell_id: 'non-existent-id',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockChildProcess.kill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not found');
|
||||
it('should return error for non-existent shell_id', async () => {
|
||||
const result = await ctr.handleGetCommandOutput({
|
||||
shell_id: 'non-existent',
|
||||
});
|
||||
|
||||
it('should remove process from map after killing', async () => {
|
||||
await shellCommandCtr.handleKillCommand({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
// Try to get output from killed process
|
||||
const outputResult = await shellCommandCtr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(outputResult.success).toBe(false);
|
||||
expect(outputResult.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should handle kill error gracefully', async () => {
|
||||
mockChildProcess.kill.mockImplementation(() => {
|
||||
throw new Error('Kill failed');
|
||||
});
|
||||
|
||||
const result = await shellCommandCtr.handleKillCommand({
|
||||
shell_id: 'test-uuid-123',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Kill failed');
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src/main'),
|
||||
'~common': resolve(__dirname, './src/common'),
|
||||
'@lobechat/local-file-shell': resolve(__dirname, '../../packages/local-file-shell/src'),
|
||||
},
|
||||
coverage: {
|
||||
all: false,
|
||||
|
||||
Reference in New Issue
Block a user