MAESTRO: Add remote-fs module for SSH file system operations

Creates src/main/utils/remote-fs.ts with SSH wrappers for:
- readDirRemote: ls -1AF command parsing
- readFileRemote: cat command for file contents
- statRemote: stat command with GNU/BSD format support
- directorySizeRemote: du command for directory sizes
- writeFileRemote: base64-encoded file writing
- existsRemote: test -e path existence check
- mkdirRemote: directory creation

All functions use shell escaping for security and return
RemoteFsResult<T> with success/failure status and error messages.

Includes 43 unit tests covering output parsing, error handling,
and SSH context integration.
This commit is contained in:
Pedram Amini
2025-12-30 01:57:58 -06:00
parent bc7e9d12ad
commit 6a498ffcda
2 changed files with 1145 additions and 0 deletions

View File

@@ -0,0 +1,662 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
readDirRemote,
readFileRemote,
statRemote,
directorySizeRemote,
writeFileRemote,
existsRemote,
mkdirRemote,
type RemoteFsDeps,
} from '../../../main/utils/remote-fs';
import type { SshRemoteConfig } from '../../../shared/types';
import type { ExecResult } from '../../../main/utils/execFile';
describe('remote-fs', () => {
// Base SSH config for testing
const baseConfig: SshRemoteConfig = {
id: 'test-remote-1',
name: 'Test Remote',
host: 'dev.example.com',
port: 22,
username: 'testuser',
privateKeyPath: '~/.ssh/id_ed25519',
enabled: true,
};
// Create mock dependencies
function createMockDeps(execResult: ExecResult): RemoteFsDeps {
return {
execSsh: vi.fn().mockResolvedValue(execResult),
buildSshArgs: vi.fn().mockReturnValue([
'-i', '/home/user/.ssh/id_ed25519',
'-o', 'BatchMode=yes',
'-p', '22',
'testuser@dev.example.com',
]),
};
}
describe('readDirRemote', () => {
it('parses ls output correctly for regular files and directories', async () => {
const deps = createMockDeps({
stdout: 'file1.txt\nfile2.js\nsrc/\nnode_modules/\n',
stderr: '',
exitCode: 0,
});
const result = await readDirRemote('/home/user/project', baseConfig, deps);
expect(result.success).toBe(true);
expect(result.data).toEqual([
{ name: 'file1.txt', isDirectory: false, isSymlink: false },
{ name: 'file2.js', isDirectory: false, isSymlink: false },
{ name: 'src', isDirectory: true, isSymlink: false },
{ name: 'node_modules', isDirectory: true, isSymlink: false },
]);
});
it('identifies symbolic links from ls -F output', async () => {
const deps = createMockDeps({
stdout: 'link-to-dir@\nlink-to-file@\nregular.txt\n',
stderr: '',
exitCode: 0,
});
const result = await readDirRemote('/home/user', baseConfig, deps);
expect(result.success).toBe(true);
expect(result.data).toEqual([
{ name: 'link-to-dir', isDirectory: false, isSymlink: true },
{ name: 'link-to-file', isDirectory: false, isSymlink: true },
{ name: 'regular.txt', isDirectory: false, isSymlink: false },
]);
});
it('handles hidden files (from -A flag)', async () => {
const deps = createMockDeps({
stdout: '.gitignore\n.env\npackage.json\nsrc/\n',
stderr: '',
exitCode: 0,
});
const result = await readDirRemote('/project', baseConfig, deps);
expect(result.success).toBe(true);
expect(result.data?.map(e => e.name)).toContain('.gitignore');
expect(result.data?.map(e => e.name)).toContain('.env');
});
it('strips executable indicator (*) from files', async () => {
const deps = createMockDeps({
stdout: 'run.sh*\nscript.py*\ndata.txt\n',
stderr: '',
exitCode: 0,
});
const result = await readDirRemote('/scripts', baseConfig, deps);
expect(result.success).toBe(true);
expect(result.data).toEqual([
{ name: 'run.sh', isDirectory: false, isSymlink: false },
{ name: 'script.py', isDirectory: false, isSymlink: false },
{ name: 'data.txt', isDirectory: false, isSymlink: false },
]);
});
it('returns error when directory does not exist', async () => {
const deps = createMockDeps({
stdout: '__LS_ERROR__\n',
stderr: '',
exitCode: 0,
});
const result = await readDirRemote('/nonexistent', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('not found or not accessible');
});
it('returns error on SSH failure', async () => {
const deps = createMockDeps({
stdout: '',
stderr: 'Permission denied',
exitCode: 1,
});
const result = await readDirRemote('/protected', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('Permission denied');
});
it('handles empty directory', async () => {
const deps = createMockDeps({
stdout: '',
stderr: '',
exitCode: 0,
});
const result = await readDirRemote('/empty-dir', baseConfig, deps);
expect(result.success).toBe(true);
expect(result.data).toEqual([]);
});
it('builds correct SSH command with escaped path', async () => {
const deps = createMockDeps({
stdout: 'file.txt\n',
stderr: '',
exitCode: 0,
});
await readDirRemote("/path/with spaces/and'quotes", baseConfig, deps);
expect(deps.execSsh).toHaveBeenCalledWith('ssh', expect.any(Array));
const call = (deps.execSsh as any).mock.calls[0][1];
const remoteCommand = call[call.length - 1];
// Path should be properly escaped in the command
expect(remoteCommand).toContain("'/path/with spaces/and'\\''quotes'");
});
});
describe('readFileRemote', () => {
it('returns file contents successfully', async () => {
const deps = createMockDeps({
stdout: '# README\n\nThis is my project.\n',
stderr: '',
exitCode: 0,
});
const result = await readFileRemote('/project/README.md', baseConfig, deps);
expect(result.success).toBe(true);
expect(result.data).toBe('# README\n\nThis is my project.\n');
});
it('handles file not found error', async () => {
const deps = createMockDeps({
stdout: '',
stderr: 'cat: /missing.txt: No such file or directory',
exitCode: 1,
});
const result = await readFileRemote('/missing.txt', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('File not found');
});
it('handles permission denied error', async () => {
const deps = createMockDeps({
stdout: '',
stderr: 'cat: /etc/shadow: Permission denied',
exitCode: 1,
});
const result = await readFileRemote('/etc/shadow', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('Permission denied');
});
it('handles reading directory error', async () => {
const deps = createMockDeps({
stdout: '',
stderr: 'cat: /etc/: Is a directory',
exitCode: 1,
});
const result = await readFileRemote('/etc/', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('is a directory');
});
it('handles empty file', async () => {
const deps = createMockDeps({
stdout: '',
stderr: '',
exitCode: 0,
});
const result = await readFileRemote('/empty.txt', baseConfig, deps);
expect(result.success).toBe(true);
expect(result.data).toBe('');
});
it('preserves binary-safe content (within UTF-8)', async () => {
const deps = createMockDeps({
stdout: 'Line 1\nLine 2\r\nLine 3\tTabbed',
stderr: '',
exitCode: 0,
});
const result = await readFileRemote('/file.txt', baseConfig, deps);
expect(result.success).toBe(true);
expect(result.data).toBe('Line 1\nLine 2\r\nLine 3\tTabbed');
});
});
describe('statRemote', () => {
it('parses GNU stat output for regular file', async () => {
const deps = createMockDeps({
stdout: '1234\nregular file\n1703836800\n',
stderr: '',
exitCode: 0,
});
const result = await statRemote('/project/package.json', baseConfig, deps);
expect(result.success).toBe(true);
expect(result.data).toEqual({
size: 1234,
isDirectory: false,
mtime: 1703836800000, // Converted to milliseconds
});
});
it('parses GNU stat output for directory', async () => {
const deps = createMockDeps({
stdout: '4096\ndirectory\n1703836800\n',
stderr: '',
exitCode: 0,
});
const result = await statRemote('/project/src', baseConfig, deps);
expect(result.success).toBe(true);
expect(result.data?.isDirectory).toBe(true);
});
it('parses BSD stat output format', async () => {
// BSD stat -f '%z\n%HT\n%m' format
const deps = createMockDeps({
stdout: '5678\nRegular File\n1703836800\n',
stderr: '',
exitCode: 0,
});
const result = await statRemote('/project/file.txt', baseConfig, deps);
expect(result.success).toBe(true);
expect(result.data).toEqual({
size: 5678,
isDirectory: false,
mtime: 1703836800000,
});
});
it('handles file not found', async () => {
const deps = createMockDeps({
stdout: '',
stderr: "stat: cannot stat '/missing': No such file or directory",
exitCode: 1,
});
const result = await statRemote('/missing', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
it('handles permission denied', async () => {
const deps = createMockDeps({
stdout: '',
stderr: "stat: cannot stat '/protected': Permission denied",
exitCode: 1,
});
const result = await statRemote('/protected', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('Permission denied');
});
it('handles invalid output format', async () => {
const deps = createMockDeps({
stdout: 'invalid\n',
stderr: '',
exitCode: 0,
});
const result = await statRemote('/file', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid stat output');
});
it('handles non-numeric values in output', async () => {
const deps = createMockDeps({
stdout: 'notanumber\nregular file\nalsonotanumber\n',
stderr: '',
exitCode: 0,
});
const result = await statRemote('/file', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('Failed to parse stat output');
});
});
describe('directorySizeRemote', () => {
it('parses du -sb output (GNU)', async () => {
const deps = createMockDeps({
stdout: '123456789\t/project\n',
stderr: '',
exitCode: 0,
});
const result = await directorySizeRemote('/project', baseConfig, deps);
expect(result.success).toBe(true);
expect(result.data).toBe(123456789);
});
it('parses awk-processed du -sk output (BSD fallback)', async () => {
const deps = createMockDeps({
stdout: '1234567890\n', // Awk output (size * 1024)
stderr: '',
exitCode: 0,
});
const result = await directorySizeRemote('/project', baseConfig, deps);
expect(result.success).toBe(true);
expect(result.data).toBe(1234567890);
});
it('handles directory not found', async () => {
const deps = createMockDeps({
stdout: '',
stderr: "du: cannot access '/missing': No such file or directory",
exitCode: 1,
});
const result = await directorySizeRemote('/missing', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
it('handles permission denied', async () => {
const deps = createMockDeps({
stdout: '',
stderr: "du: cannot read directory '/protected': Permission denied",
exitCode: 1,
});
const result = await directorySizeRemote('/protected', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('Permission denied');
});
it('handles invalid output format', async () => {
const deps = createMockDeps({
stdout: 'invalid output\n',
stderr: '',
exitCode: 0,
});
const result = await directorySizeRemote('/dir', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('Failed to parse du output');
});
});
describe('writeFileRemote', () => {
it('writes content successfully using base64 encoding', async () => {
const deps = createMockDeps({
stdout: '',
stderr: '',
exitCode: 0,
});
const result = await writeFileRemote('/output.txt', 'Hello, World!', baseConfig, deps);
expect(result.success).toBe(true);
// Verify the SSH command includes base64-encoded content
const call = (deps.execSsh as any).mock.calls[0][1];
const remoteCommand = call[call.length - 1];
expect(remoteCommand).toContain('base64 -d');
});
it('handles content with special characters', async () => {
const deps = createMockDeps({
stdout: '',
stderr: '',
exitCode: 0,
});
const content = "Line 1\nLine 2 with 'quotes' and $variables";
const result = await writeFileRemote('/output.txt', content, baseConfig, deps);
expect(result.success).toBe(true);
// Verify base64 encoding is used (safe for special chars)
const call = (deps.execSsh as any).mock.calls[0][1];
const remoteCommand = call[call.length - 1];
expect(remoteCommand).toContain(Buffer.from(content, 'utf-8').toString('base64'));
});
it('handles permission denied on write', async () => {
const deps = createMockDeps({
stdout: '',
stderr: '/etc/test.txt: Permission denied',
exitCode: 1,
});
const result = await writeFileRemote('/etc/test.txt', 'test', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('Permission denied');
});
it('handles parent directory not found', async () => {
const deps = createMockDeps({
stdout: '',
stderr: '/nonexistent/file.txt: No such file or directory',
exitCode: 1,
});
const result = await writeFileRemote('/nonexistent/file.txt', 'test', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('Parent directory not found');
});
});
describe('existsRemote', () => {
it('returns true when path exists', async () => {
const deps = createMockDeps({
stdout: 'EXISTS\n',
stderr: '',
exitCode: 0,
});
const result = await existsRemote('/home/user/file.txt', baseConfig, deps);
expect(result.success).toBe(true);
expect(result.data).toBe(true);
});
it('returns false when path does not exist', async () => {
const deps = createMockDeps({
stdout: 'NOT_EXISTS\n',
stderr: '',
exitCode: 0,
});
const result = await existsRemote('/nonexistent', baseConfig, deps);
expect(result.success).toBe(true);
expect(result.data).toBe(false);
});
it('handles SSH error', async () => {
const deps = createMockDeps({
stdout: '',
stderr: 'Connection refused',
exitCode: 1,
});
const result = await existsRemote('/path', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('mkdirRemote', () => {
it('creates directory successfully', async () => {
const deps = createMockDeps({
stdout: '',
stderr: '',
exitCode: 0,
});
const result = await mkdirRemote('/home/user/newdir', baseConfig, true, deps);
expect(result.success).toBe(true);
});
it('uses -p flag for recursive creation', async () => {
const deps = createMockDeps({
stdout: '',
stderr: '',
exitCode: 0,
});
await mkdirRemote('/home/user/a/b/c', baseConfig, true, deps);
const call = (deps.execSsh as any).mock.calls[0][1];
const remoteCommand = call[call.length - 1];
expect(remoteCommand).toContain('mkdir -p');
});
it('omits -p flag when recursive is false', async () => {
const deps = createMockDeps({
stdout: '',
stderr: '',
exitCode: 0,
});
await mkdirRemote('/home/user/newdir', baseConfig, false, deps);
const call = (deps.execSsh as any).mock.calls[0][1];
const remoteCommand = call[call.length - 1];
expect(remoteCommand).toContain('mkdir ');
expect(remoteCommand).not.toContain('-p');
});
it('handles permission denied', async () => {
const deps = createMockDeps({
stdout: '',
stderr: "mkdir: cannot create directory '/etc/test': Permission denied",
exitCode: 1,
});
const result = await mkdirRemote('/etc/test', baseConfig, true, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('Permission denied');
});
it('handles directory already exists', async () => {
const deps = createMockDeps({
stdout: '',
stderr: "mkdir: cannot create directory '/home': File exists",
exitCode: 1,
});
const result = await mkdirRemote('/home', baseConfig, false, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('already exists');
});
});
describe('SSH context integration', () => {
it('passes correct SSH remote config to buildSshArgs', async () => {
const customConfig: SshRemoteConfig = {
...baseConfig,
host: 'custom.host.com',
port: 2222,
username: 'customuser',
};
const deps = createMockDeps({
stdout: 'file.txt\n',
stderr: '',
exitCode: 0,
});
await readDirRemote('/path', customConfig, deps);
expect(deps.buildSshArgs).toHaveBeenCalledWith(customConfig);
});
it('handles useSshConfig mode correctly', async () => {
const sshConfigMode: SshRemoteConfig = {
...baseConfig,
useSshConfig: true,
privateKeyPath: '',
username: '',
};
const deps = createMockDeps({
stdout: 'EXISTS\n',
stderr: '',
exitCode: 0,
});
await existsRemote('/test', sshConfigMode, deps);
expect(deps.buildSshArgs).toHaveBeenCalledWith(sshConfigMode);
});
});
describe('error handling edge cases', () => {
it('handles network timeout', async () => {
const deps = createMockDeps({
stdout: '',
stderr: 'Connection timed out',
exitCode: 255,
});
const result = await readFileRemote('/file.txt', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toContain('timed out');
});
it('handles SSH authentication failure', async () => {
const deps = createMockDeps({
stdout: '',
stderr: 'Permission denied (publickey)',
exitCode: 255,
});
const result = await statRemote('/file', baseConfig, deps);
expect(result.success).toBe(false);
});
it('handles empty response with non-zero exit code', async () => {
const deps = createMockDeps({
stdout: '',
stderr: '',
exitCode: 1,
});
const result = await readFileRemote('/file', baseConfig, deps);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
});

483
src/main/utils/remote-fs.ts Normal file
View File

@@ -0,0 +1,483 @@
/**
* Remote File System utilities for SSH remote execution.
*
* Provides functions to perform file system operations on remote hosts via SSH.
* These utilities enable File Explorer, Auto Run, and other features to work
* when a session is running on a remote host.
*
* All functions accept a SshRemoteConfig and execute the corresponding
* Unix commands (ls, cat, stat, du) via SSH, parsing their output.
*/
import { SshRemoteConfig } from '../../shared/types';
import { execFileNoThrow, ExecResult } from './execFile';
import { shellEscape } from './shell-escape';
import { sshRemoteManager } from '../ssh-remote-manager';
/**
* File or directory entry returned from readDir operations.
*/
export interface RemoteDirEntry {
/** File or directory name */
name: string;
/** Whether this entry is a directory */
isDirectory: boolean;
/** Whether this entry is a symbolic link */
isSymlink: boolean;
}
/**
* File stat information returned from stat operations.
*/
export interface RemoteStatResult {
/** File size in bytes */
size: number;
/** Whether this is a directory */
isDirectory: boolean;
/** Modification time as Unix timestamp (milliseconds) */
mtime: number;
}
/**
* Result wrapper for remote fs operations.
* Includes success/failure status and optional error message.
*/
export interface RemoteFsResult<T> {
/** Whether the operation succeeded */
success: boolean;
/** The result data (if success is true) */
data?: T;
/** Error message (if success is false) */
error?: string;
}
/**
* Dependencies that can be injected for testing.
*/
export interface RemoteFsDeps {
/** Function to execute SSH commands */
execSsh: (command: string, args: string[]) => Promise<ExecResult>;
/** Function to build SSH args from config */
buildSshArgs: (config: SshRemoteConfig) => string[];
}
/**
* Default dependencies using real implementations.
*/
const defaultDeps: RemoteFsDeps = {
execSsh: (command: string, args: string[]): Promise<ExecResult> => {
return execFileNoThrow(command, args);
},
buildSshArgs: (config: SshRemoteConfig): string[] => {
return sshRemoteManager.buildSshArgs(config);
},
};
/**
* Execute a command on a remote host via SSH.
*
* @param config SSH remote configuration
* @param remoteCommand The shell command to execute on the remote
* @param deps Optional dependencies for testing
* @returns ExecResult with stdout, stderr, and exitCode
*/
async function execRemoteCommand(
config: SshRemoteConfig,
remoteCommand: string,
deps: RemoteFsDeps = defaultDeps
): Promise<ExecResult> {
const sshArgs = deps.buildSshArgs(config);
sshArgs.push(remoteCommand);
return deps.execSsh('ssh', sshArgs);
}
/**
* Read directory contents from a remote host via SSH.
*
* Executes `ls -la` on the remote and parses the output to extract
* file names, types (directory, file, symlink), and other metadata.
*
* @param dirPath Path to the directory on the remote host
* @param sshRemote SSH remote configuration
* @param deps Optional dependencies for testing
* @returns Array of directory entries
*
* @example
* const entries = await readDirRemote('/home/user/project', sshConfig);
* // => [{ name: 'src', isDirectory: true, isSymlink: false }, ...]
*/
export async function readDirRemote(
dirPath: string,
sshRemote: SshRemoteConfig,
deps: RemoteFsDeps = defaultDeps
): Promise<RemoteFsResult<RemoteDirEntry[]>> {
// Use ls with specific options:
// -1: One entry per line
// -A: Show hidden files except . and ..
// -F: Append indicator (/ for dirs, @ for symlinks, * for executables)
// --color=never: Disable color codes in output
// We avoid -l because parsing long format is complex and locale-dependent
const escapedPath = shellEscape(dirPath);
const remoteCommand = `ls -1AF --color=never ${escapedPath} 2>/dev/null || echo "__LS_ERROR__"`;
const result = await execRemoteCommand(sshRemote, remoteCommand, deps);
if (result.exitCode !== 0 && !result.stdout.includes('__LS_ERROR__')) {
return {
success: false,
error: result.stderr || `ls failed with exit code ${result.exitCode}`,
};
}
// Check for our error marker
if (result.stdout.trim() === '__LS_ERROR__') {
return {
success: false,
error: `Directory not found or not accessible: ${dirPath}`,
};
}
const entries: RemoteDirEntry[] = [];
const lines = result.stdout.trim().split('\n').filter(Boolean);
for (const line of lines) {
if (!line || line === '__LS_ERROR__') continue;
let name = line;
let isDirectory = false;
let isSymlink = false;
// Parse the indicator suffix from -F flag
if (name.endsWith('/')) {
name = name.slice(0, -1);
isDirectory = true;
} else if (name.endsWith('@')) {
name = name.slice(0, -1);
isSymlink = true;
} else if (name.endsWith('*')) {
// Executable file - remove the indicator
name = name.slice(0, -1);
} else if (name.endsWith('|')) {
// Named pipe - remove the indicator
name = name.slice(0, -1);
} else if (name.endsWith('=')) {
// Socket - remove the indicator
name = name.slice(0, -1);
}
// Skip empty names (shouldn't happen, but be safe)
if (!name) continue;
entries.push({ name, isDirectory, isSymlink });
}
return {
success: true,
data: entries,
};
}
/**
* Read file contents from a remote host via SSH.
*
* Executes `cat` on the remote to read the file contents.
* For binary files or very large files, consider using different approaches.
*
* @param filePath Path to the file on the remote host
* @param sshRemote SSH remote configuration
* @param deps Optional dependencies for testing
* @returns File contents as a string
*
* @example
* const content = await readFileRemote('/home/user/project/README.md', sshConfig);
* // => '# My Project\n...'
*/
export async function readFileRemote(
filePath: string,
sshRemote: SshRemoteConfig,
deps: RemoteFsDeps = defaultDeps
): Promise<RemoteFsResult<string>> {
const escapedPath = shellEscape(filePath);
// Use cat with explicit error handling
const remoteCommand = `cat ${escapedPath}`;
const result = await execRemoteCommand(sshRemote, remoteCommand, deps);
if (result.exitCode !== 0) {
const error = result.stderr || `Failed to read file: ${filePath}`;
return {
success: false,
error: error.includes('No such file')
? `File not found: ${filePath}`
: error.includes('Is a directory')
? `Path is a directory: ${filePath}`
: error.includes('Permission denied')
? `Permission denied: ${filePath}`
: error,
};
}
return {
success: true,
data: result.stdout,
};
}
/**
* Get file/directory stat information from a remote host via SSH.
*
* Executes `stat` on the remote with a specific format string to get
* size, type, and modification time.
*
* @param filePath Path to the file or directory on the remote host
* @param sshRemote SSH remote configuration
* @param deps Optional dependencies for testing
* @returns Stat information (size, isDirectory, mtime)
*
* @example
* const stats = await statRemote('/home/user/project/package.json', sshConfig);
* // => { size: 1234, isDirectory: false, mtime: 1703836800000 }
*/
export async function statRemote(
filePath: string,
sshRemote: SshRemoteConfig,
deps: RemoteFsDeps = defaultDeps
): Promise<RemoteFsResult<RemoteStatResult>> {
const escapedPath = shellEscape(filePath);
// Use stat with format string:
// %s = size in bytes
// %F = file type (regular file, directory, symbolic link, etc.)
// %Y = modification time as Unix timestamp (seconds)
// Note: GNU stat vs BSD stat have different format specifiers
// We try GNU format first (Linux), then BSD format (macOS)
const remoteCommand = `stat --printf='%s\\n%F\\n%Y' ${escapedPath} 2>/dev/null || stat -f '%z\\n%HT\\n%m' ${escapedPath}`;
const result = await execRemoteCommand(sshRemote, remoteCommand, deps);
if (result.exitCode !== 0) {
const error = result.stderr || `Failed to stat: ${filePath}`;
return {
success: false,
error: error.includes('No such file')
? `Path not found: ${filePath}`
: error.includes('Permission denied')
? `Permission denied: ${filePath}`
: error,
};
}
const lines = result.stdout.trim().split('\n');
if (lines.length < 3) {
return {
success: false,
error: `Invalid stat output for: ${filePath}`,
};
}
const size = parseInt(lines[0], 10);
const fileType = lines[1].toLowerCase();
const mtimeSeconds = parseInt(lines[2], 10);
if (isNaN(size) || isNaN(mtimeSeconds)) {
return {
success: false,
error: `Failed to parse stat output for: ${filePath}`,
};
}
// Determine if it's a directory from the file type string
// GNU stat returns: "regular file", "directory", "symbolic link"
// BSD stat returns: "Regular File", "Directory", "Symbolic Link"
const isDirectory = fileType.includes('directory');
return {
success: true,
data: {
size,
isDirectory,
mtime: mtimeSeconds * 1000, // Convert to milliseconds
},
};
}
/**
* Get total size of a directory from a remote host via SSH.
*
* Executes `du -sb` on the remote to calculate the total size
* of all files in the directory.
*
* @param dirPath Path to the directory on the remote host
* @param sshRemote SSH remote configuration
* @param deps Optional dependencies for testing
* @returns Total size in bytes
*
* @example
* const size = await directorySizeRemote('/home/user/project', sshConfig);
* // => 1234567890
*/
export async function directorySizeRemote(
dirPath: string,
sshRemote: SshRemoteConfig,
deps: RemoteFsDeps = defaultDeps
): Promise<RemoteFsResult<number>> {
const escapedPath = shellEscape(dirPath);
// Use du with:
// -s: summarize (total only)
// -b: apparent size in bytes (GNU)
// If -b not available (BSD), use -k and multiply by 1024
const remoteCommand = `du -sb ${escapedPath} 2>/dev/null || du -sk ${escapedPath} 2>/dev/null | awk '{print $1 * 1024}'`;
const result = await execRemoteCommand(sshRemote, remoteCommand, deps);
if (result.exitCode !== 0) {
const error = result.stderr || `Failed to get directory size: ${dirPath}`;
return {
success: false,
error: error.includes('No such file')
? `Directory not found: ${dirPath}`
: error.includes('Permission denied')
? `Permission denied: ${dirPath}`
: error,
};
}
// Parse the size from the output (first field)
const output = result.stdout.trim();
const match = output.match(/^(\d+)/);
if (!match) {
return {
success: false,
error: `Failed to parse du output for: ${dirPath}`,
};
}
const size = parseInt(match[1], 10);
if (isNaN(size)) {
return {
success: false,
error: `Invalid size value for: ${dirPath}`,
};
}
return {
success: true,
data: size,
};
}
/**
* Write file contents to a remote host via SSH.
*
* Uses cat with a heredoc to safely write content to a file on the remote.
* This is safe for text content but not recommended for binary files.
*
* @param filePath Path to the file on the remote host
* @param content Content to write
* @param sshRemote SSH remote configuration
* @param deps Optional dependencies for testing
* @returns Success/failure result
*
* @example
* const result = await writeFileRemote('/home/user/project/output.txt', 'Hello!', sshConfig);
* // => { success: true }
*/
export async function writeFileRemote(
filePath: string,
content: string,
sshRemote: SshRemoteConfig,
deps: RemoteFsDeps = defaultDeps
): Promise<RemoteFsResult<void>> {
const escapedPath = shellEscape(filePath);
// Use base64 encoding to safely transfer the content
// This avoids issues with special characters, quotes, and newlines
const base64Content = Buffer.from(content, 'utf-8').toString('base64');
// Decode base64 on remote and write to file
const remoteCommand = `echo '${base64Content}' | base64 -d > ${escapedPath}`;
const result = await execRemoteCommand(sshRemote, remoteCommand, deps);
if (result.exitCode !== 0) {
const error = result.stderr || `Failed to write file: ${filePath}`;
return {
success: false,
error: error.includes('Permission denied')
? `Permission denied: ${filePath}`
: error.includes('No such file')
? `Parent directory not found: ${filePath}`
: error,
};
}
return { success: true };
}
/**
* Check if a path exists on a remote host via SSH.
*
* @param remotePath Path to check
* @param sshRemote SSH remote configuration
* @param deps Optional dependencies for testing
* @returns Whether the path exists
*/
export async function existsRemote(
remotePath: string,
sshRemote: SshRemoteConfig,
deps: RemoteFsDeps = defaultDeps
): Promise<RemoteFsResult<boolean>> {
const escapedPath = shellEscape(remotePath);
const remoteCommand = `test -e ${escapedPath} && echo "EXISTS" || echo "NOT_EXISTS"`;
const result = await execRemoteCommand(sshRemote, remoteCommand, deps);
if (result.exitCode !== 0) {
return {
success: false,
error: result.stderr || 'Failed to check path existence',
};
}
return {
success: true,
data: result.stdout.trim() === 'EXISTS',
};
}
/**
* Create a directory on a remote host via SSH.
*
* @param dirPath Directory path to create
* @param sshRemote SSH remote configuration
* @param recursive Whether to create parent directories (mkdir -p)
* @param deps Optional dependencies for testing
* @returns Success/failure result
*/
export async function mkdirRemote(
dirPath: string,
sshRemote: SshRemoteConfig,
recursive: boolean = true,
deps: RemoteFsDeps = defaultDeps
): Promise<RemoteFsResult<void>> {
const escapedPath = shellEscape(dirPath);
const mkdirFlag = recursive ? '-p' : '';
const remoteCommand = `mkdir ${mkdirFlag} ${escapedPath}`;
const result = await execRemoteCommand(sshRemote, remoteCommand, deps);
if (result.exitCode !== 0) {
const error = result.stderr || `Failed to create directory: ${dirPath}`;
return {
success: false,
error: error.includes('Permission denied')
? `Permission denied: ${dirPath}`
: error.includes('File exists')
? `Directory already exists: ${dirPath}`
: error,
};
}
return { success: true };
}