♻️ 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:
Arvin Xu
2026-03-11 00:04:22 +08:00
committed by GitHub
parent c2e9b45d4c
commit 860e11ab3a
56 changed files with 4313 additions and 2560 deletions

View File

@@ -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",

View File

@@ -3,4 +3,5 @@ packages:
- '../../packages/electron-client-ipc'
- '../../packages/file-loaders'
- '../../packages/desktop-bridge'
- '../../packages/local-file-shell'
- '.'

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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');
});
});

View File

@@ -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,