Merge pull request #121 from pedramamini/1-ssh-tunnel-agents

ssh tunneling enhancements
This commit is contained in:
Pedram Amini
2025-12-30 04:00:19 -06:00
committed by GitHub
32 changed files with 2575 additions and 148 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();
});
});
});

View File

@@ -360,7 +360,8 @@ describe('AutoRun', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith(
'/test/folder',
'test-doc.md',
'Updated content'
'Updated content',
undefined // sshRemoteId (undefined for local sessions)
);
});
@@ -663,7 +664,8 @@ describe('AutoRun', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith(
'/test/folder',
'test-doc.md',
'new content'
'new content',
undefined // sshRemoteId (undefined for local sessions)
);
expect(onOpenBatchRunner).toHaveBeenCalled();
});

View File

@@ -216,7 +216,8 @@ describe('AutoRun Save Path Correctness', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith(
'/projects/alpha/docs',
'Phase-1.md',
'Modified content for alpha'
'Modified content for alpha',
undefined // sshRemoteId (undefined for local sessions)
);
});
@@ -240,7 +241,8 @@ describe('AutoRun Save Path Correctness', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith(
'/projects/beta/auto-run',
'Tasks.md',
'Updated tasks list'
'Updated tasks list',
undefined // sshRemoteId (undefined for local sessions)
);
});
@@ -263,7 +265,8 @@ describe('AutoRun Save Path Correctness', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith(
'/home/user/gamma-project/docs',
'README.md',
'Updated readme content'
'Updated readme content',
undefined // sshRemoteId (undefined for local sessions)
);
});
@@ -286,7 +289,8 @@ describe('AutoRun Save Path Correctness', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenLastCalledWith(
'/delta/path',
'doc1.md',
'Version 2'
'Version 2',
undefined // sshRemoteId (undefined for local sessions)
);
// Simulate saved content update (file watcher triggers contentVersion change)
@@ -299,7 +303,8 @@ describe('AutoRun Save Path Correctness', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenLastCalledWith(
'/delta/path',
'doc1.md',
'Version 3'
'Version 3',
undefined // sshRemoteId (undefined for local sessions)
);
expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledTimes(2);
@@ -609,7 +614,8 @@ describe('AutoRun Save Path Correctness', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith(
'/rapid/test',
'rapid-doc.md',
'Final typed content'
'Final typed content',
undefined // sshRemoteId (undefined for local sessions)
);
});
@@ -630,7 +636,8 @@ describe('AutoRun Save Path Correctness', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith(
'/empty/test',
'empty-doc.md',
''
'',
undefined // sshRemoteId (undefined for local sessions)
);
});
@@ -651,7 +658,8 @@ describe('AutoRun Save Path Correctness', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith(
'/whitespace/test',
'ws-doc.md',
' \n\n '
' \n\n ',
undefined // sshRemoteId (undefined for local sessions)
);
});
@@ -673,7 +681,8 @@ describe('AutoRun Save Path Correctness', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith(
'/special/test',
'special-doc.md',
specialContent
specialContent,
undefined // sshRemoteId (undefined for local sessions)
);
});
@@ -696,7 +705,8 @@ describe('AutoRun Save Path Correctness', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith(
'/long/test',
'long-doc.md',
longContent
longContent,
undefined // sshRemoteId (undefined for local sessions)
);
});
});

View File

@@ -506,7 +506,8 @@ describe('AutoRun Content Synchronization Race Conditions', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith(
'/test/path',
'my-doc.md',
'New content to save'
'New content to save',
undefined // sshRemoteId (undefined for local sessions)
);
});

View File

@@ -411,7 +411,8 @@ describe('AutoRun Session Isolation', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith(
'/session-a-folder',
'my-doc.md',
'New content'
'New content',
undefined // sshRemoteId (undefined for local sessions)
);
// Should NOT be called with any other path
@@ -605,7 +606,8 @@ describe('AutoRun Session Isolation', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith(
'/folder-a',
'doc-a.md',
'Modified'
'Modified',
undefined // sshRemoteId (undefined for local sessions)
);
});
});
@@ -667,7 +669,8 @@ describe('AutoRun Folder Path Isolation', () => {
expect(mockMaestro.autorun.writeDoc).toHaveBeenCalledWith(
'/unique/session/path',
'unique-doc.md',
'Changed'
'Changed',
undefined // sshRemoteId (undefined for local sessions)
);
});
});

View File

@@ -1057,7 +1057,8 @@ describe('useBatchProcessor hook', () => {
expect(mockWorktreeSetup).toHaveBeenCalledWith(
'/test/path',
'/test/worktree',
'feature/test'
'feature/test',
undefined // sshRemoteId (undefined for local sessions)
);
});
@@ -1138,7 +1139,8 @@ describe('useBatchProcessor hook', () => {
expect(mockWorktreeCheckout).toHaveBeenCalledWith(
'/test/worktree',
'feature/test',
true
true,
undefined // sshRemoteId (undefined for local sessions)
);
});
});
@@ -2853,7 +2855,8 @@ describe('useBatchProcessor hook', () => {
expect(mockWorktreeCheckout).toHaveBeenCalledWith(
'/test/worktree',
'feature-branch',
true
true,
undefined // sshRemoteId (undefined for local sessions)
);
// Should have spawned agent with worktree path

View File

@@ -138,7 +138,9 @@ describe('useFileTreeManagement', () => {
returnedChanges = await result.current.refreshFileTree(state.getSessions()[0].id);
});
expect(loadFileTree).toHaveBeenCalledWith('/test/project');
// loadFileTree is now called with (path, maxDepth, currentDepth, sshContext)
// For local sessions (no sshRemoteId), sshContext is undefined
expect(loadFileTree).toHaveBeenCalledWith('/test/project', 10, 0, undefined);
expect(compareFileTrees).toHaveBeenCalledWith(initialTree, nextTree);
expect(returnedChanges).toEqual(changes);
expect(state.getSessions()[0].fileTree).toEqual(nextTree);
@@ -187,7 +189,8 @@ describe('useFileTreeManagement', () => {
await result.current.refreshGitFileState(session.id);
});
expect(loadFileTree).toHaveBeenCalledWith('/test/shell');
// loadFileTree is now called with (path, maxDepth, currentDepth, sshContext)
expect(loadFileTree).toHaveBeenCalledWith('/test/shell', 10, 0, undefined);
expect(gitService.isRepo).toHaveBeenCalledWith('/test/shell');
expect(gitService.getBranches).toHaveBeenCalledWith('/test/shell');
expect(gitService.getTags).toHaveBeenCalledWith('/test/shell');
@@ -243,8 +246,37 @@ describe('useFileTreeManagement', () => {
renderHook(() => useFileTreeManagement(deps));
await waitFor(() => {
expect(loadFileTree).toHaveBeenCalledWith('/test/project');
// loadFileTree is now called with (path, maxDepth, currentDepth, sshContext)
expect(loadFileTree).toHaveBeenCalledWith('/test/project', 10, 0, undefined);
expect(state.getSessions()[0].fileTree).toEqual(nextTree);
});
});
it('passes SSH context when session has sshRemoteId', async () => {
const nextTree: FileNode[] = [{ name: 'remote-file.txt', type: 'file' }];
const changes = { totalChanges: 0, newFiles: 0, newFolders: 0, removedFiles: 0, removedFolders: 0 };
vi.mocked(loadFileTree).mockResolvedValue(nextTree);
vi.mocked(compareFileTrees).mockReturnValue(changes);
// Create session with SSH context
const sshSession = createMockSession({
fileTree: [],
sshRemoteId: 'my-ssh-remote',
remoteCwd: '/remote/project',
});
const state = createSessionsState([sshSession]);
const deps = createDeps(state);
const { result } = renderHook(() => useFileTreeManagement(deps));
await act(async () => {
await result.current.refreshFileTree(sshSession.id);
});
// Verify SSH context is passed to loadFileTree
expect(loadFileTree).toHaveBeenCalledWith('/test/project', 10, 0, {
sshRemoteId: 'my-ssh-remote',
remoteCwd: '/remote/project',
});
});
});

View File

@@ -198,7 +198,8 @@ describe('useWorktreeValidation', () => {
expect(result.current.validation.branchMismatch).toBe(true);
expect(result.current.validation.hasUncommittedChanges).toBe(true);
expect(mockGit.status).toHaveBeenCalledWith('/path/to/worktree');
// sshRemoteId is undefined when not provided in deps
expect(mockGit.status).toHaveBeenCalledWith('/path/to/worktree', undefined);
});
it('does not check uncommitted changes when repos differ', async () => {

View File

@@ -40,7 +40,7 @@ describe('gitService', () => {
const result = await gitService.isRepo('/path/to/repo');
expect(result).toBe(true);
expect(mockGit.isRepo).toHaveBeenCalledWith('/path/to/repo');
expect(mockGit.isRepo).toHaveBeenCalledWith('/path/to/repo', undefined);
});
test('returns false when directory is not a git repository', async () => {
@@ -49,7 +49,16 @@ describe('gitService', () => {
const result = await gitService.isRepo('/path/to/non-repo');
expect(result).toBe(false);
expect(mockGit.isRepo).toHaveBeenCalledWith('/path/to/non-repo');
expect(mockGit.isRepo).toHaveBeenCalledWith('/path/to/non-repo', undefined);
});
test('passes sshRemoteId for remote repository check', async () => {
mockGit.isRepo.mockResolvedValue(true);
const result = await gitService.isRepo('/remote/path', 'ssh-remote-123');
expect(result).toBe(true);
expect(mockGit.isRepo).toHaveBeenCalledWith('/remote/path', 'ssh-remote-123');
});
test('returns false and logs error when IPC call fails', async () => {
@@ -167,7 +176,8 @@ UU both-changed-in-merge.ts`;
const result = await gitService.getDiff('/path/to/repo');
expect(result.diff).toBe(diffOutput);
expect(mockGit.diff).toHaveBeenCalledWith('/path/to/repo');
// When no files are specified, sshRemoteId defaults to undefined
expect(mockGit.diff).toHaveBeenCalledWith('/path/to/repo', undefined, undefined);
});
test('returns diff for all files when empty array specified', async () => {
@@ -177,7 +187,8 @@ UU both-changed-in-merge.ts`;
const result = await gitService.getDiff('/path/to/repo', []);
expect(result.diff).toBe(diffOutput);
expect(mockGit.diff).toHaveBeenCalledWith('/path/to/repo');
// sshRemoteId defaults to undefined
expect(mockGit.diff).toHaveBeenCalledWith('/path/to/repo', undefined, undefined);
});
test('returns diff for specific files when files array provided', async () => {
@@ -190,8 +201,18 @@ UU both-changed-in-merge.ts`;
const result = await gitService.getDiff('/path/to/repo', ['file1.ts', 'file2.ts']);
expect(result).toEqual({ diff: `${diffOutput1}\n${diffOutput2}` });
expect(mockGit.diff).toHaveBeenNthCalledWith(1, '/path/to/repo', 'file1.ts');
expect(mockGit.diff).toHaveBeenNthCalledWith(2, '/path/to/repo', 'file2.ts');
// sshRemoteId defaults to undefined
expect(mockGit.diff).toHaveBeenNthCalledWith(1, '/path/to/repo', 'file1.ts', undefined);
expect(mockGit.diff).toHaveBeenNthCalledWith(2, '/path/to/repo', 'file2.ts', undefined);
});
test('passes sshRemoteId for remote diff', async () => {
mockGit.diff.mockResolvedValue({ stdout: 'remote diff' });
const result = await gitService.getDiff('/remote/path', undefined, 'ssh-remote-123');
expect(result.diff).toBe('remote diff');
expect(mockGit.diff).toHaveBeenCalledWith('/remote/path', undefined, 'ssh-remote-123');
});
test('returns empty diff string on error', async () => {
@@ -375,7 +396,16 @@ invalid_line`;
const result = await gitService.getBranches('/path/to/repo');
expect(result).toEqual(['main', 'develop', 'feature/test']);
expect(mockGit.branches).toHaveBeenCalledWith('/path/to/repo');
expect(mockGit.branches).toHaveBeenCalledWith('/path/to/repo', undefined);
});
test('passes sshRemoteId for remote branches', async () => {
mockGit.branches.mockResolvedValue({ branches: ['main'] });
const result = await gitService.getBranches('/remote/path', 'ssh-remote-123');
expect(result).toEqual(['main']);
expect(mockGit.branches).toHaveBeenCalledWith('/remote/path', 'ssh-remote-123');
});
test('returns empty array when result.branches is undefined', async () => {
@@ -403,7 +433,16 @@ invalid_line`;
const result = await gitService.getTags('/path/to/repo');
expect(result).toEqual(['v1.0.0', 'v1.1.0', 'v2.0.0']);
expect(mockGit.tags).toHaveBeenCalledWith('/path/to/repo');
expect(mockGit.tags).toHaveBeenCalledWith('/path/to/repo', undefined);
});
test('passes sshRemoteId for remote tags', async () => {
mockGit.tags.mockResolvedValue({ tags: ['v1.0.0'] });
const result = await gitService.getTags('/remote/path', 'ssh-remote-123');
expect(result).toEqual(['v1.0.0']);
expect(mockGit.tags).toHaveBeenCalledWith('/remote/path', 'ssh-remote-123');
});
test('returns empty array when result.tags is undefined', async () => {

View File

@@ -209,7 +209,8 @@ describe('fileExplorer utils', () => {
const result = await loadFileTree('/project');
expect(window.maestro.fs.readDir).toHaveBeenCalledWith('/project');
// Should pass undefined for sshRemoteId when no SSH context is provided
expect(window.maestro.fs.readDir).toHaveBeenCalledWith('/project', undefined);
expect(result).toHaveLength(3);
expect(result[0]).toEqual({
name: 'src',
@@ -366,6 +367,33 @@ describe('fileExplorer utils', () => {
expect(result).toHaveLength(1);
expect(result[0].name).toBe('regular.txt');
});
it('passes SSH context to readDir for remote file operations', async () => {
vi.mocked(window.maestro.fs.readDir)
.mockResolvedValueOnce([
{ name: 'src', isFile: false, isDirectory: true },
{ name: 'README.md', isFile: true, isDirectory: false },
])
.mockResolvedValue([]); // Empty children for src folder
const sshContext = { sshRemoteId: 'remote-1', remoteCwd: '/home/user' };
const result = await loadFileTree('/project', 10, 0, sshContext);
// Verify SSH remote ID is passed to all readDir calls
expect(window.maestro.fs.readDir).toHaveBeenCalledWith('/project', 'remote-1');
expect(window.maestro.fs.readDir).toHaveBeenCalledWith('/project/src', 'remote-1');
expect(result).toHaveLength(2);
});
it('passes undefined to readDir when no SSH context is provided', async () => {
vi.mocked(window.maestro.fs.readDir).mockResolvedValueOnce([
{ name: 'file.txt', isFile: true, isDirectory: false },
]);
await loadFileTree('/project');
expect(window.maestro.fs.readDir).toHaveBeenCalledWith('/project', undefined);
});
});
// ============================================================================

View File

@@ -23,6 +23,7 @@ import { initializeOutputParsers, getOutputParser } from './parsers';
import { DEMO_MODE, DEMO_DATA_PATH } from './constants';
import type { SshRemoteConfig } from '../shared/types';
import { initAutoUpdater } from './auto-updater';
import { readDirRemote, readFileRemote, statRemote, directorySizeRemote } from './utils/remote-fs';
// ============================================================================
// Custom Storage Location Configuration
@@ -278,6 +279,15 @@ const agentSessionOriginsStore = new Store<AgentSessionOriginsData>({
},
});
/**
* Get SSH remote configuration by ID.
* Returns undefined if not found.
*/
function getSshRemoteById(sshRemoteId: string): SshRemoteConfig | undefined {
const sshRemotes = store.get('sshRemotes', []) as SshRemoteConfig[];
return sshRemotes.find((r) => r.id === sshRemoteId);
}
let mainWindow: BrowserWindow | null = null;
let processManager: ProcessManager | null = null;
let webServer: WebServer | null = null;
@@ -994,6 +1004,7 @@ function setupIpcHandlers() {
mainWindow,
getMainWindow: () => mainWindow,
app,
settingsStore: store,
});
// Playbook operations - extracted to src/main/ipc/handlers/playbooks.ts
@@ -1149,7 +1160,26 @@ function setupIpcHandlers() {
return os.homedir();
});
ipcMain.handle('fs:readDir', async (_, dirPath: string) => {
ipcMain.handle('fs:readDir', async (_, dirPath: string, sshRemoteId?: string) => {
// SSH remote: dispatch to remote fs operations
if (sshRemoteId) {
const sshConfig = getSshRemoteById(sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
const result = await readDirRemote(dirPath, sshConfig);
if (!result.success) {
throw new Error(result.error || 'Failed to read remote directory');
}
// Map remote entries to match local format (isFile derived from !isDirectory && !isSymlink)
return result.data!.map((entry) => ({
name: entry.name,
isDirectory: entry.isDirectory,
isFile: !entry.isDirectory && !entry.isSymlink,
}));
}
// Local: use standard fs operations
const entries = await fs.readdir(dirPath, { withFileTypes: true });
// Convert Dirent objects to plain objects for IPC serialization
return entries.map((entry: any) => ({
@@ -1159,8 +1189,33 @@ function setupIpcHandlers() {
}));
});
ipcMain.handle('fs:readFile', async (_, filePath: string) => {
ipcMain.handle('fs:readFile', async (_, filePath: string, sshRemoteId?: string) => {
try {
// SSH remote: dispatch to remote fs operations
if (sshRemoteId) {
const sshConfig = getSshRemoteById(sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
const result = await readFileRemote(filePath, sshConfig);
if (!result.success) {
throw new Error(result.error || 'Failed to read remote file');
}
// For images over SSH, we'd need to base64 encode on remote and decode here
// For now, return raw content (text files work, binary images may have issues)
const ext = filePath.split('.').pop()?.toLowerCase();
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico'];
const isImage = imageExtensions.includes(ext || '');
if (isImage) {
// The remote readFile returns raw bytes as string - convert to base64 data URL
const mimeType = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`;
const base64 = Buffer.from(result.data!, 'binary').toString('base64');
return `data:${mimeType};base64,${base64}`;
}
return result.data!;
}
// Local: use standard fs operations
// Check if file is an image
const ext = filePath.split('.').pop()?.toLowerCase();
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico'];
@@ -1182,8 +1237,31 @@ function setupIpcHandlers() {
}
});
ipcMain.handle('fs:stat', async (_, filePath: string) => {
ipcMain.handle('fs:stat', async (_, filePath: string, sshRemoteId?: string) => {
try {
// SSH remote: dispatch to remote fs operations
if (sshRemoteId) {
const sshConfig = getSshRemoteById(sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
const result = await statRemote(filePath, sshConfig);
if (!result.success) {
throw new Error(result.error || 'Failed to get remote file stats');
}
// Map remote stat result to match local format
// Note: remote stat doesn't provide createdAt (birthtime), use mtime as fallback
const mtimeDate = new Date(result.data!.mtime);
return {
size: result.data!.size,
createdAt: mtimeDate.toISOString(), // Fallback: use mtime for createdAt
modifiedAt: mtimeDate.toISOString(),
isDirectory: result.data!.isDirectory,
isFile: !result.data!.isDirectory,
};
}
// Local: use standard fs operations
const stats = await fs.stat(filePath);
return {
size: stats.size,
@@ -1199,7 +1277,27 @@ function setupIpcHandlers() {
// Calculate total size of a directory recursively
// Respects the same ignore patterns as loadFileTree (node_modules, __pycache__)
ipcMain.handle('fs:directorySize', async (_, dirPath: string) => {
ipcMain.handle('fs:directorySize', async (_, dirPath: string, sshRemoteId?: string) => {
// SSH remote: dispatch to remote fs operations
if (sshRemoteId) {
const sshConfig = getSshRemoteById(sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
const result = await directorySizeRemote(dirPath, sshConfig);
if (!result.success) {
throw new Error(result.error || 'Failed to get remote directory size');
}
// Remote directorySizeRemote only returns totalSize (via du -sb)
// File/folder counts are not available without recursive listing
return {
totalSize: result.data!,
fileCount: 0, // Not available from remote du command
folderCount: 0, // Not available from remote du command
};
}
// Local: use standard fs operations
let totalSize = 0;
let fileCount = 0;
let folderCount = 0;

View File

@@ -2,8 +2,18 @@ import { ipcMain, BrowserWindow, App } from 'electron';
import fs from 'fs/promises';
import path from 'path';
import chokidar, { FSWatcher } from 'chokidar';
import Store from 'electron-store';
import { logger } from '../../utils/logger';
import { createIpcHandler, CreateHandlerOptions } from '../../utils/ipcHandler';
import { SshRemoteConfig } from '../../../shared/types';
import { MaestroSettings } from './persistence';
import {
readDirRemote,
readFileRemote,
writeFileRemote,
existsRemote,
mkdirRemote,
} from '../../utils/remote-fs';
const LOG_CONTEXT = '[AutoRun]';
@@ -14,6 +24,31 @@ const handlerOpts = (operation: string, logSuccess = true): CreateHandlerOptions
logSuccess,
});
/**
* Dependencies required for Auto Run handler registration.
* Optional for backward compatibility - SSH remote support requires settingsStore.
*/
export interface AutorunHandlerDependencies {
/** The settings store (MaestroSettings) - required for SSH remote lookup */
settingsStore?: Store<MaestroSettings>;
}
/**
* Get SSH remote configuration by ID from the settings store.
* Returns undefined if not found or store not provided.
*/
function getSshRemoteById(
store: Store<MaestroSettings> | undefined,
sshRemoteId: string
): SshRemoteConfig | undefined {
if (!store) {
logger.warn(`${LOG_CONTEXT} Settings store not available for SSH remote lookup`, LOG_CONTEXT);
return undefined;
}
const sshRemotes = store.get('sshRemotes', []) as SshRemoteConfig[];
return sshRemotes.find((r) => r.id === sshRemoteId);
}
// State managed by this module
const autoRunWatchers = new Map<string, FSWatcher>();
let autoRunWatchDebounceTimer: NodeJS.Timeout | null = null;
@@ -79,6 +114,61 @@ async function scanDirectory(dirPath: string, relativePath: string = ''): Promis
return nodes;
}
/**
* Recursively scan directory for markdown files on a remote host via SSH.
* This is the SSH version of scanDirectory.
*/
async function scanDirectoryRemote(
dirPath: string,
sshRemote: SshRemoteConfig,
relativePath: string = ''
): Promise<TreeNode[]> {
const result = await readDirRemote(dirPath, sshRemote);
if (!result.success || !result.data) {
logger.warn(`${LOG_CONTEXT} Failed to read remote directory: ${result.error}`, LOG_CONTEXT);
return [];
}
const nodes: TreeNode[] = [];
// Sort entries: folders first, then files, both alphabetically
const sortedEntries = result.data
.filter((entry) => !entry.name.startsWith('.'))
.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
for (const entry of sortedEntries) {
const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
if (entry.isDirectory) {
// Recursively scan subdirectory
// Use forward slashes for remote paths (Unix style)
const children = await scanDirectoryRemote(`${dirPath}/${entry.name}`, sshRemote, entryRelativePath);
// Only include folders that contain .md files (directly or in subfolders)
if (children.length > 0) {
nodes.push({
name: entry.name,
type: 'folder',
path: entryRelativePath,
children,
});
}
} else if (!entry.isDirectory && !entry.isSymlink && entry.name.toLowerCase().endsWith('.md')) {
// Add .md file (without extension in name, but keep in path)
nodes.push({
name: entry.name.slice(0, -3),
type: 'file',
path: entryRelativePath.slice(0, -3), // Remove .md from path too
});
}
}
return nodes;
}
/**
* Flatten tree structure to flat list of paths.
*
@@ -119,19 +209,37 @@ function validatePathWithinFolder(filePath: string, folderPath: string): boolean
* - Image management (save, delete, list)
* - Folder watching for external changes
* - Folder deletion (wizard "start fresh" feature)
*
* SSH remote support: Handlers accept optional sshRemoteId parameter for remote file operations.
*/
export function registerAutorunHandlers(deps: {
mainWindow: BrowserWindow | null;
getMainWindow: () => BrowserWindow | null;
app: App;
}): void {
const { getMainWindow, app } = deps;
} & AutorunHandlerDependencies): void {
const { getMainWindow, app, settingsStore } = deps;
// List markdown files in a directory for Auto Run (with recursive subfolder support)
// Supports SSH remote execution via optional sshRemoteId parameter
ipcMain.handle(
'autorun:listDocs',
createIpcHandler(handlerOpts('listDocs'), async (folderPath: string) => {
// Validate the folder path exists
createIpcHandler(handlerOpts('listDocs'), async (folderPath: string, sshRemoteId?: string) => {
// SSH remote: dispatch to remote operations
if (sshRemoteId) {
const sshConfig = getSshRemoteById(settingsStore, sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
logger.debug(`${LOG_CONTEXT} listDocs via SSH: ${folderPath}`, LOG_CONTEXT);
const tree = await scanDirectoryRemote(folderPath, sshConfig);
const files = flattenTree(tree);
logger.info(`Listed ${files.length} remote markdown files in ${folderPath} (with subfolders)`, LOG_CONTEXT);
return { files, tree };
}
// Local: Validate the folder path exists
const folderStat = await fs.stat(folderPath);
if (!folderStat.isDirectory()) {
throw new Error('Path is not a directory');
@@ -146,9 +254,10 @@ export function registerAutorunHandlers(deps: {
);
// Read a markdown document for Auto Run (supports subdirectories)
// Supports SSH remote execution via optional sshRemoteId parameter
ipcMain.handle(
'autorun:readDoc',
createIpcHandler(handlerOpts('readDoc'), async (folderPath: string, filename: string) => {
createIpcHandler(handlerOpts('readDoc'), async (folderPath: string, filename: string, sshRemoteId?: string) => {
// Reject obvious traversal attempts
if (filename.includes('..')) {
throw new Error('Invalid filename');
@@ -157,6 +266,27 @@ export function registerAutorunHandlers(deps: {
// Ensure filename has .md extension
const fullFilename = filename.endsWith('.md') ? filename : `${filename}.md`;
// SSH remote: dispatch to remote operations
if (sshRemoteId) {
const sshConfig = getSshRemoteById(settingsStore, sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
// Construct remote path (use forward slashes)
const remotePath = `${folderPath}/${fullFilename}`;
logger.debug(`${LOG_CONTEXT} readDoc via SSH: ${remotePath}`, LOG_CONTEXT);
const result = await readFileRemote(remotePath, sshConfig);
if (!result.success || result.data === undefined) {
throw new Error(result.error || 'Failed to read remote file');
}
logger.info(`Read remote Auto Run doc: ${fullFilename}`, LOG_CONTEXT);
return { content: result.data };
}
// Local: Validate and read
const filePath = path.join(folderPath, fullFilename);
// Validate the file is within the folder path (prevent traversal)
@@ -180,9 +310,10 @@ export function registerAutorunHandlers(deps: {
);
// Write a markdown document for Auto Run (supports subdirectories)
// Supports SSH remote execution via optional sshRemoteId parameter
ipcMain.handle(
'autorun:writeDoc',
createIpcHandler(handlerOpts('writeDoc'), async (folderPath: string, filename: string, content: string) => {
createIpcHandler(handlerOpts('writeDoc'), async (folderPath: string, filename: string, content: string, sshRemoteId?: string) => {
// DEBUG: Log all write attempts to trace cross-session contamination
logger.info(
`[DEBUG] writeDoc called: folder=${folderPath}, file=${filename}, content.length=${content.length}, content.slice(0,50)="${content.slice(0, 50).replace(/\n/g, '\\n')}"`,
@@ -198,6 +329,40 @@ export function registerAutorunHandlers(deps: {
// Ensure filename has .md extension
const fullFilename = filename.endsWith('.md') ? filename : `${filename}.md`;
// SSH remote: dispatch to remote operations
if (sshRemoteId) {
const sshConfig = getSshRemoteById(settingsStore, sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
// Construct remote path (use forward slashes)
const remotePath = `${folderPath}/${fullFilename}`;
// Ensure parent directory exists on remote
const remoteParentDir = remotePath.substring(0, remotePath.lastIndexOf('/'));
if (remoteParentDir && remoteParentDir !== folderPath) {
const parentExists = await existsRemote(remoteParentDir, sshConfig);
if (!parentExists.success || !parentExists.data) {
const mkdirResult = await mkdirRemote(remoteParentDir, sshConfig, true);
if (!mkdirResult.success) {
throw new Error(mkdirResult.error || 'Failed to create remote parent directory');
}
}
}
logger.debug(`${LOG_CONTEXT} writeDoc via SSH: ${remotePath}`, LOG_CONTEXT);
const result = await writeFileRemote(remotePath, content, sshConfig);
if (!result.success) {
throw new Error(result.error || 'Failed to write remote file');
}
logger.info(`Wrote remote Auto Run doc: ${fullFilename}`, LOG_CONTEXT);
return {};
}
// Local: Validate and write
const filePath = path.join(folderPath, fullFilename);
// Validate the file is within the folder path (prevent traversal)
@@ -402,10 +567,35 @@ export function registerAutorunHandlers(deps: {
);
// Start watching an Auto Run folder for changes
// Supports SSH remote execution via optional sshRemoteId parameter
// For remote sessions, file watching is not supported (chokidar can't watch remote directories)
// Returns isRemote: true to indicate the UI should poll using listDocs instead
ipcMain.handle(
'autorun:watchFolder',
createIpcHandler(handlerOpts('watchFolder'), async (folderPath: string) => {
// Stop any existing watcher for this folder
createIpcHandler(handlerOpts('watchFolder'), async (folderPath: string, sshRemoteId?: string) => {
// SSH remote: Cannot use chokidar for remote directories
// Return success with isRemote flag so UI can fall back to polling
if (sshRemoteId) {
const sshConfig = getSshRemoteById(settingsStore, sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
// Ensure remote folder exists (create if not)
const folderExists = await existsRemote(folderPath, sshConfig);
if (!folderExists.success || !folderExists.data) {
const mkdirResult = await mkdirRemote(folderPath, sshConfig, true);
if (!mkdirResult.success) {
throw new Error(mkdirResult.error || 'Failed to create remote Auto Run folder');
}
logger.info(`Created remote Auto Run folder: ${folderPath}`, LOG_CONTEXT);
}
logger.info(`Remote Auto Run folder ready (polling mode): ${folderPath}`, LOG_CONTEXT);
return { isRemote: true, message: 'File watching not available for remote sessions. Use polling.' };
}
// Local: Stop any existing watcher for this folder
if (autoRunWatchers.has(folderPath)) {
autoRunWatchers.get(folderPath)?.close();
autoRunWatchers.delete(folderPath);

View File

@@ -16,6 +16,13 @@ import {
getImageMimeType,
} from '../../../shared/gitUtils';
import { SshRemoteConfig } from '../../../shared/types';
import {
worktreeInfoRemote,
worktreeSetupRemote,
worktreeCheckoutRemote,
listWorktreesRemote,
getRepoRootRemote,
} from '../../utils/remote-git';
const LOG_CONTEXT = '[Git]';
@@ -68,7 +75,7 @@ const handlerOpts = (operation: string, logSuccess = false): CreateHandlerOption
* - Basic operations: status, diff, branch, remote, tags
* - Advanced queries: log, info, commitCount
* - File operations: show, showFile
* - Worktree management: worktreeInfo, worktreeSetup, worktreeCheckout
* - Worktree management: worktreeInfo, worktreeSetup, worktreeCheckout (with SSH support)
* - GitHub CLI integration: checkGhCli, createPR, getDefaultBranch
*
* @param deps Dependencies including settingsStore for SSH remote configuration lookup
@@ -314,9 +321,25 @@ export function registerGitHandlers(deps: GitHandlerDependencies): void {
// Git worktree operations for Auto Run parallelization
// Get information about a worktree at a given path
// Supports SSH remote execution via optional sshRemoteId parameter
ipcMain.handle('git:worktreeInfo', createIpcHandler(
handlerOpts('worktreeInfo'),
async (worktreePath: string) => {
async (worktreePath: string, sshRemoteId?: string) => {
// SSH remote: dispatch to remote git operations
if (sshRemoteId) {
const sshConfig = getSshRemoteById(sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
logger.debug(`${LOG_CONTEXT} worktreeInfo via SSH: ${worktreePath}`, LOG_CONTEXT);
const result = await worktreeInfoRemote(worktreePath, sshConfig);
if (!result.success || !result.data) {
throw new Error(result.error || 'Remote worktreeInfo failed');
}
return result.data;
}
// Local execution (existing code)
// Check if the path exists
try {
await fs.access(worktreePath);
@@ -375,9 +398,25 @@ export function registerGitHandlers(deps: GitHandlerDependencies): void {
));
// Get the root directory of the git repository
// Supports SSH remote execution via optional sshRemoteId parameter
ipcMain.handle('git:getRepoRoot', createIpcHandler(
handlerOpts('getRepoRoot'),
async (cwd: string) => {
async (cwd: string, sshRemoteId?: string) => {
// SSH remote: dispatch to remote git operations
if (sshRemoteId) {
const sshConfig = getSshRemoteById(sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
logger.debug(`${LOG_CONTEXT} getRepoRoot via SSH: ${cwd}`, LOG_CONTEXT);
const result = await getRepoRootRemote(cwd, sshConfig);
if (!result.success) {
throw new Error(result.error || 'Not a git repository');
}
return { root: result.data };
}
// Local execution
const result = await execFileNoThrow('git', ['rev-parse', '--show-toplevel'], cwd);
if (result.exitCode !== 0) {
throw new Error(result.stderr || 'Not a git repository');
@@ -387,9 +426,25 @@ export function registerGitHandlers(deps: GitHandlerDependencies): void {
));
// Create or reuse a worktree
// Supports SSH remote execution via optional sshRemoteId parameter
ipcMain.handle('git:worktreeSetup', withIpcErrorLogging(
handlerOpts('worktreeSetup'),
async (mainRepoCwd: string, worktreePath: string, branchName: string) => {
async (mainRepoCwd: string, worktreePath: string, branchName: string, sshRemoteId?: string) => {
// SSH remote: dispatch to remote git operations
if (sshRemoteId) {
const sshConfig = getSshRemoteById(sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
logger.debug(`${LOG_CONTEXT} worktreeSetup via SSH: ${JSON.stringify({ mainRepoCwd, worktreePath, branchName })}`, LOG_CONTEXT);
const result = await worktreeSetupRemote(mainRepoCwd, worktreePath, branchName, sshConfig);
if (!result.success) {
throw new Error(result.error || 'Remote worktreeSetup failed');
}
return result.data;
}
// Local execution (existing code)
logger.debug(`worktreeSetup called with: ${JSON.stringify({ mainRepoCwd, worktreePath, branchName })}`, LOG_CONTEXT);
// Resolve paths to absolute for proper comparison
@@ -497,9 +552,25 @@ export function registerGitHandlers(deps: GitHandlerDependencies): void {
));
// Checkout a branch in a worktree (with uncommitted changes check)
// Supports SSH remote execution via optional sshRemoteId parameter
ipcMain.handle('git:worktreeCheckout', withIpcErrorLogging(
handlerOpts('worktreeCheckout'),
async (worktreePath: string, branchName: string, createIfMissing: boolean) => {
async (worktreePath: string, branchName: string, createIfMissing: boolean, sshRemoteId?: string) => {
// SSH remote: dispatch to remote git operations
if (sshRemoteId) {
const sshConfig = getSshRemoteById(sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
logger.debug(`${LOG_CONTEXT} worktreeCheckout via SSH: ${JSON.stringify({ worktreePath, branchName, createIfMissing })}`, LOG_CONTEXT);
const result = await worktreeCheckoutRemote(worktreePath, branchName, createIfMissing, sshConfig);
if (!result.success) {
throw new Error(result.error || 'Remote worktreeCheckout failed');
}
return result.data;
}
// Local execution (existing code)
// Check for uncommitted changes
const statusResult = await execFileNoThrow('git', ['status', '--porcelain'], worktreePath);
if (statusResult.exitCode !== 0) {
@@ -646,9 +717,25 @@ export function registerGitHandlers(deps: GitHandlerDependencies): void {
));
// List all worktrees for a git repository
// Supports SSH remote execution via optional sshRemoteId parameter
ipcMain.handle('git:listWorktrees', createIpcHandler(
handlerOpts('listWorktrees'),
async (cwd: string) => {
async (cwd: string, sshRemoteId?: string) => {
// SSH remote: dispatch to remote git operations
if (sshRemoteId) {
const sshConfig = getSshRemoteById(sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
logger.debug(`${LOG_CONTEXT} listWorktrees via SSH: ${cwd}`, LOG_CONTEXT);
const result = await listWorktreesRemote(cwd, sshConfig);
if (!result.success) {
throw new Error(result.error || 'Remote listWorktrees failed');
}
return { worktrees: result.data };
}
// Local execution (existing code)
// Run git worktree list --porcelain for machine-readable output
const result = await execFileNoThrow('git', ['worktree', 'list', '--porcelain'], cwd);
if (result.exitCode !== 0) {
@@ -781,9 +868,19 @@ export function registerGitHandlers(deps: GitHandlerDependencies): void {
));
// Watch a worktree directory for new worktrees
// Note: File watching is not supported for SSH remote sessions.
// Remote sessions will get success: true but isRemote: true flag indicating
// watching is not active. The UI should periodically poll listWorktrees instead.
ipcMain.handle('git:watchWorktreeDirectory', createIpcHandler(
handlerOpts('watchWorktreeDirectory'),
async (sessionId: string, worktreePath: string) => {
async (sessionId: string, worktreePath: string, sshRemoteId?: string) => {
// SSH remote: file watching is not supported
// Return success with isRemote flag so UI knows to poll instead
if (sshRemoteId) {
logger.debug(`${LOG_CONTEXT} Worktree watching not supported for SSH remote sessions. Session ${sessionId} should poll instead.`, LOG_CONTEXT);
return { success: true, isRemote: true, message: 'File watching not available for remote sessions. Use polling instead.' };
}
// Stop existing watcher if any
const existingWatcher = worktreeWatchers.get(sessionId);
if (existingWatcher) {

View File

@@ -9,7 +9,7 @@
import { BrowserWindow, App } from 'electron';
import Store from 'electron-store';
import { registerGitHandlers } from './git';
import { registerGitHandlers, GitHandlerDependencies } from './git';
import { registerAutorunHandlers } from './autorun';
import { registerPlaybooksHandlers } from './playbooks';
import { registerHistoryHandlers } from './history';
@@ -69,6 +69,7 @@ export type { ContextHandlerDependencies };
export type { StatsHandlerDependencies };
export type { DocumentGraphHandlerDependencies };
export type { SshRemoteHandlerDependencies };
export type { GitHandlerDependencies };
export type { MaestroSettings, SessionsData, GroupsData };
/**

View File

@@ -300,6 +300,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
id: sshRemoteUsed.id,
name: sshRemoteUsed.name,
host: sshRemoteUsed.host,
remoteWorkingDir: sshRemoteUsed.remoteWorkingDir,
} : null;
mainWindow.webContents.send('process:ssh-remote', config.sessionId, sshRemoteInfo);
}
@@ -311,6 +312,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
id: sshRemoteUsed.id,
name: sshRemoteUsed.name,
host: sshRemoteUsed.host,
remoteWorkingDir: sshRemoteUsed.remoteWorkingDir,
} : undefined,
};
})

View File

@@ -149,8 +149,9 @@ contextBridge.exposeInMainWorld('maestro', {
},
// SSH remote execution status
// Emitted when a process starts executing via SSH on a remote host
onSshRemote: (callback: (sessionId: string, sshRemote: { id: string; name: string; host: string } | null) => void) => {
const handler = (_: any, sessionId: string, sshRemote: { id: string; name: string; host: string } | null) => callback(sessionId, sshRemote);
// Includes remoteWorkingDir for session-wide SSH context (file explorer, git, auto run, etc.)
onSshRemote: (callback: (sessionId: string, sshRemote: { id: string; name: string; host: string; remoteWorkingDir?: string } | null) => void) => {
const handler = (_: any, sessionId: string, sshRemote: { id: string; name: string; host: string; remoteWorkingDir?: string } | null) => callback(sessionId, sshRemote);
ipcRenderer.on('process:ssh-remote', handler);
return () => ipcRenderer.removeListener('process:ssh-remote', handler);
},
@@ -384,8 +385,9 @@ contextBridge.exposeInMainWorld('maestro', {
showFile: (cwd: string, ref: string, filePath: string) =>
ipcRenderer.invoke('git:showFile', cwd, ref, filePath) as Promise<{ content?: string; error?: string }>,
// Git worktree operations for Auto Run parallelization
worktreeInfo: (worktreePath: string) =>
ipcRenderer.invoke('git:worktreeInfo', worktreePath) as Promise<{
// All worktree operations support SSH remote execution via optional sshRemoteId parameter
worktreeInfo: (worktreePath: string, sshRemoteId?: string) =>
ipcRenderer.invoke('git:worktreeInfo', worktreePath, sshRemoteId) as Promise<{
success: boolean;
exists?: boolean;
isWorktree?: boolean;
@@ -393,14 +395,14 @@ contextBridge.exposeInMainWorld('maestro', {
repoRoot?: string;
error?: string;
}>,
getRepoRoot: (cwd: string) =>
ipcRenderer.invoke('git:getRepoRoot', cwd) as Promise<{
getRepoRoot: (cwd: string, sshRemoteId?: string) =>
ipcRenderer.invoke('git:getRepoRoot', cwd, sshRemoteId) as Promise<{
success: boolean;
root?: string;
error?: string;
}>,
worktreeSetup: (mainRepoCwd: string, worktreePath: string, branchName: string) =>
ipcRenderer.invoke('git:worktreeSetup', mainRepoCwd, worktreePath, branchName) as Promise<{
worktreeSetup: (mainRepoCwd: string, worktreePath: string, branchName: string, sshRemoteId?: string) =>
ipcRenderer.invoke('git:worktreeSetup', mainRepoCwd, worktreePath, branchName, sshRemoteId) as Promise<{
success: boolean;
created?: boolean;
currentBranch?: string;
@@ -408,8 +410,8 @@ contextBridge.exposeInMainWorld('maestro', {
branchMismatch?: boolean;
error?: string;
}>,
worktreeCheckout: (worktreePath: string, branchName: string, createIfMissing: boolean) =>
ipcRenderer.invoke('git:worktreeCheckout', worktreePath, branchName, createIfMissing) as Promise<{
worktreeCheckout: (worktreePath: string, branchName: string, createIfMissing: boolean, sshRemoteId?: string) =>
ipcRenderer.invoke('git:worktreeCheckout', worktreePath, branchName, createIfMissing, sshRemoteId) as Promise<{
success: boolean;
hasUncommittedChanges: boolean;
error?: string;
@@ -439,8 +441,9 @@ contextBridge.exposeInMainWorld('maestro', {
error?: string;
}>,
// List all worktrees for a git repository
listWorktrees: (cwd: string) =>
ipcRenderer.invoke('git:listWorktrees', cwd) as Promise<{
// Supports SSH remote execution via optional sshRemoteId parameter
listWorktrees: (cwd: string, sshRemoteId?: string) =>
ipcRenderer.invoke('git:listWorktrees', cwd, sshRemoteId) as Promise<{
worktrees: Array<{
path: string;
head: string;
@@ -460,10 +463,14 @@ contextBridge.exposeInMainWorld('maestro', {
}>;
}>,
// Watch a worktree directory for new worktrees
watchWorktreeDirectory: (sessionId: string, worktreePath: string) =>
ipcRenderer.invoke('git:watchWorktreeDirectory', sessionId, worktreePath) as Promise<{
// Note: File watching is not available for SSH remote sessions.
// For remote sessions, returns isRemote: true indicating polling should be used instead.
watchWorktreeDirectory: (sessionId: string, worktreePath: string, sshRemoteId?: string) =>
ipcRenderer.invoke('git:watchWorktreeDirectory', sessionId, worktreePath, sshRemoteId) as Promise<{
success: boolean;
error?: string;
isRemote?: boolean;
message?: string;
}>,
// Stop watching a worktree directory
unwatchWorktreeDirectory: (sessionId: string) =>
@@ -488,13 +495,16 @@ contextBridge.exposeInMainWorld('maestro', {
// File System API
fs: {
homeDir: () => ipcRenderer.invoke('fs:homeDir') as Promise<string>,
readDir: (dirPath: string) => ipcRenderer.invoke('fs:readDir', dirPath),
readFile: (filePath: string) => ipcRenderer.invoke('fs:readFile', filePath),
readDir: (dirPath: string, sshRemoteId?: string) =>
ipcRenderer.invoke('fs:readDir', dirPath, sshRemoteId),
readFile: (filePath: string, sshRemoteId?: string) =>
ipcRenderer.invoke('fs:readFile', filePath, sshRemoteId),
writeFile: (filePath: string, content: string) =>
ipcRenderer.invoke('fs:writeFile', filePath, content) as Promise<{ success: boolean }>,
stat: (filePath: string) => ipcRenderer.invoke('fs:stat', filePath),
directorySize: (dirPath: string) =>
ipcRenderer.invoke('fs:directorySize', dirPath) as Promise<{
stat: (filePath: string, sshRemoteId?: string) =>
ipcRenderer.invoke('fs:stat', filePath, sshRemoteId),
directorySize: (dirPath: string, sshRemoteId?: string) =>
ipcRenderer.invoke('fs:directorySize', dirPath, sshRemoteId) as Promise<{
totalSize: number;
fileCount: number;
folderCount: number;
@@ -1165,13 +1175,14 @@ contextBridge.exposeInMainWorld('maestro', {
},
// Auto Run API (file-system-based document runner)
// SSH remote support: Core operations accept optional sshRemoteId for remote file operations
autorun: {
listDocs: (folderPath: string) =>
ipcRenderer.invoke('autorun:listDocs', folderPath),
readDoc: (folderPath: string, filename: string) =>
ipcRenderer.invoke('autorun:readDoc', folderPath, filename),
writeDoc: (folderPath: string, filename: string, content: string) =>
ipcRenderer.invoke('autorun:writeDoc', folderPath, filename, content),
listDocs: (folderPath: string, sshRemoteId?: string) =>
ipcRenderer.invoke('autorun:listDocs', folderPath, sshRemoteId),
readDoc: (folderPath: string, filename: string, sshRemoteId?: string) =>
ipcRenderer.invoke('autorun:readDoc', folderPath, filename, sshRemoteId),
writeDoc: (folderPath: string, filename: string, content: string, sshRemoteId?: string) =>
ipcRenderer.invoke('autorun:writeDoc', folderPath, filename, content, sshRemoteId),
saveImage: (
folderPath: string,
docName: string,
@@ -1192,8 +1203,9 @@ contextBridge.exposeInMainWorld('maestro', {
deleteFolder: (projectPath: string) =>
ipcRenderer.invoke('autorun:deleteFolder', projectPath),
// File watching for live updates
watchFolder: (folderPath: string) =>
ipcRenderer.invoke('autorun:watchFolder', folderPath),
// For remote sessions (sshRemoteId provided), returns isRemote: true indicating polling should be used
watchFolder: (folderPath: string, sshRemoteId?: string): Promise<{ isRemote?: boolean; message?: string }> =>
ipcRenderer.invoke('autorun:watchFolder', folderPath, sshRemoteId),
unwatchFolder: (folderPath: string) =>
ipcRenderer.invoke('autorun:unwatchFolder', folderPath),
onFileChanged: (handler: (data: { folderPath: string; filename: string; eventType: string }) => void) => {
@@ -1708,7 +1720,7 @@ export interface MaestroAPI {
onSlashCommands: (callback: (sessionId: string, slashCommands: string[]) => void) => () => void;
onThinkingChunk: (callback: (sessionId: string, content: string) => void) => () => void;
onToolExecution: (callback: (sessionId: string, toolEvent: { toolName: string; state?: unknown; timestamp: number }) => void) => () => void;
onSshRemote: (callback: (sessionId: string, sshRemote: { id: string; name: string; host: string } | null) => void) => () => void;
onSshRemote: (callback: (sessionId: string, sshRemote: { id: string; name: string; host: string; remoteWorkingDir?: string } | null) => void) => () => void;
onRemoteCommand: (callback: (sessionId: string, command: string) => void) => () => void;
onRemoteSwitchMode: (callback: (sessionId: string, mode: 'ai' | 'terminal') => void) => () => void;
onRemoteInterrupt: (callback: (sessionId: string) => void) => () => void;
@@ -1839,17 +1851,17 @@ export interface MaestroAPI {
};
fs: {
homeDir: () => Promise<string>;
readDir: (dirPath: string) => Promise<DirectoryEntry[]>;
readFile: (filePath: string) => Promise<string>;
readDir: (dirPath: string, sshRemoteId?: string) => Promise<DirectoryEntry[]>;
readFile: (filePath: string, sshRemoteId?: string) => Promise<string>;
writeFile: (filePath: string, content: string) => Promise<{ success: boolean }>;
stat: (filePath: string) => Promise<{
stat: (filePath: string, sshRemoteId?: string) => Promise<{
size: number;
createdAt: string;
modifiedAt: string;
isDirectory: boolean;
isFile: boolean;
}>;
directorySize: (dirPath: string) => Promise<{
directorySize: (dirPath: string, sshRemoteId?: string) => Promise<{
totalSize: number;
fileCount: number;
folderCount: number;

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

View File

@@ -3,6 +3,9 @@
*
* Provides functionality to execute git commands on remote hosts via SSH
* when a session is configured for remote execution.
*
* These utilities enable worktree management and other git operations
* when a session is running on a remote host.
*/
import { SshRemoteConfig } from '../../shared/types';
@@ -22,6 +25,19 @@ export interface RemoteGitOptions {
remoteCwd?: string;
}
/**
* Result wrapper for remote git operations.
* Includes success/failure status and optional error message.
*/
export interface RemoteGitResult<T> {
/** Whether the operation succeeded */
success: boolean;
/** The result data (if success is true) */
data?: T;
/** Error message (if success is false) */
error?: string;
}
/**
* Execute a git command on a remote host via SSH.
*
@@ -99,3 +115,551 @@ export async function execGit(
// Local execution
return execFileNoThrow('git', args, localCwd);
}
/**
* Execute a shell command on a remote host via SSH.
*
* @param shellCommand The shell command to execute on the remote
* @param sshRemote SSH remote configuration
* @returns Execution result
*/
async function execRemoteShellCommand(
shellCommand: string,
sshRemote: SshRemoteConfig
): Promise<ExecResult> {
const remoteOptions: RemoteCommandOptions = {
command: 'sh',
args: ['-c', shellCommand],
env: sshRemote.remoteEnv,
};
const sshCommand = buildSshCommand(sshRemote, remoteOptions);
return execFileNoThrow(sshCommand.command, sshCommand.args);
}
/**
* Worktree info result from remote host.
*/
export interface RemoteWorktreeInfo extends Record<string, unknown> {
exists: boolean;
isWorktree: boolean;
currentBranch?: string;
repoRoot?: string;
}
/**
* Get information about a worktree at a given path on a remote host.
*
* @param worktreePath Path to the worktree on the remote host
* @param sshRemote SSH remote configuration
* @returns Worktree information
*/
export async function worktreeInfoRemote(
worktreePath: string,
sshRemote: SshRemoteConfig
): Promise<RemoteGitResult<RemoteWorktreeInfo>> {
// Check if path exists
const existsResult = await execRemoteShellCommand(
`test -d '${worktreePath}' && echo "EXISTS" || echo "NOT_EXISTS"`,
sshRemote
);
if (existsResult.exitCode !== 0) {
return {
success: false,
error: existsResult.stderr || 'Failed to check path existence',
};
}
if (existsResult.stdout.trim() === 'NOT_EXISTS') {
return {
success: true,
data: { exists: false, isWorktree: false },
};
}
// Check if it's a git directory
const isInsideWorkTree = await execGitRemote(
['rev-parse', '--is-inside-work-tree'],
{ sshRemote, remoteCwd: worktreePath }
);
if (isInsideWorkTree.exitCode !== 0) {
return {
success: true,
data: { exists: true, isWorktree: false },
};
}
// Get git-dir and git-common-dir to determine if it's a worktree
const gitDirResult = await execGitRemote(
['rev-parse', '--git-dir'],
{ sshRemote, remoteCwd: worktreePath }
);
if (gitDirResult.exitCode !== 0) {
return {
success: false,
error: 'Failed to get git directory',
};
}
const gitDir = gitDirResult.stdout.trim();
const gitCommonDirResult = await execGitRemote(
['rev-parse', '--git-common-dir'],
{ sshRemote, remoteCwd: worktreePath }
);
const gitCommonDir = gitCommonDirResult.exitCode === 0
? gitCommonDirResult.stdout.trim()
: gitDir;
// If git-dir and git-common-dir are different, this is a worktree
const isWorktree = gitDir !== gitCommonDir;
// Get current branch
const branchResult = await execGitRemote(
['rev-parse', '--abbrev-ref', 'HEAD'],
{ sshRemote, remoteCwd: worktreePath }
);
const currentBranch = branchResult.exitCode === 0
? branchResult.stdout.trim()
: undefined;
// Get repository root
let repoRoot: string | undefined;
if (isWorktree && gitCommonDir) {
// For worktrees, find main repo root from common dir
// Use dirname on the remote to get parent of .git folder
const repoRootResult = await execRemoteShellCommand(
`cd '${worktreePath}' && dirname $(cd '${gitCommonDir}' && pwd)`,
sshRemote
);
if (repoRootResult.exitCode === 0) {
repoRoot = repoRootResult.stdout.trim();
}
} else {
const repoRootResult = await execGitRemote(
['rev-parse', '--show-toplevel'],
{ sshRemote, remoteCwd: worktreePath }
);
if (repoRootResult.exitCode === 0) {
repoRoot = repoRootResult.stdout.trim();
}
}
return {
success: true,
data: {
exists: true,
isWorktree,
currentBranch,
repoRoot,
},
};
}
/**
* Worktree setup result.
*/
export interface RemoteWorktreeSetupResult extends Record<string, unknown> {
success: boolean;
error?: string;
created?: boolean;
currentBranch?: string;
requestedBranch?: string;
branchMismatch?: boolean;
}
/**
* Create or reuse a worktree on a remote host.
*
* @param mainRepoCwd Path to the main repository on the remote
* @param worktreePath Path where the worktree should be created
* @param branchName Branch name for the worktree
* @param sshRemote SSH remote configuration
* @returns Setup result with success/failure and branch info
*/
export async function worktreeSetupRemote(
mainRepoCwd: string,
worktreePath: string,
branchName: string,
sshRemote: SshRemoteConfig
): Promise<RemoteGitResult<RemoteWorktreeSetupResult>> {
// Check if worktree path is inside the main repo (nested worktree)
const checkNestedResult = await execRemoteShellCommand(
`realpath '${mainRepoCwd}' && realpath --canonicalize-missing '${worktreePath}'`,
sshRemote
);
if (checkNestedResult.exitCode === 0) {
const lines = checkNestedResult.stdout.trim().split('\n');
if (lines.length >= 2) {
const resolvedMainRepo = lines[0];
const resolvedWorktree = lines[1];
if (resolvedWorktree.startsWith(resolvedMainRepo + '/')) {
return {
success: true,
data: {
success: false,
error: 'Worktree path cannot be inside the main repository. Please use a sibling directory.',
},
};
}
}
}
// Check if worktree path already exists
const existsResult = await execRemoteShellCommand(
`test -d '${worktreePath}' && echo "EXISTS" || echo "NOT_EXISTS"`,
sshRemote
);
if (existsResult.exitCode !== 0) {
return {
success: false,
error: existsResult.stderr || 'Failed to check path existence',
};
}
let pathExists = existsResult.stdout.trim() === 'EXISTS';
if (pathExists) {
// Check if it's already a worktree of this repo
const worktreeInfo = await execGitRemote(
['rev-parse', '--is-inside-work-tree'],
{ sshRemote, remoteCwd: worktreePath }
);
if (worktreeInfo.exitCode !== 0) {
// Path exists but isn't a git repo - check if empty
const lsResult = await execRemoteShellCommand(
`ls -A '${worktreePath}' 2>/dev/null | head -1`,
sshRemote
);
if (lsResult.exitCode === 0 && lsResult.stdout.trim() === '') {
// Empty directory - remove it
await execRemoteShellCommand(`rmdir '${worktreePath}'`, sshRemote);
pathExists = false;
} else {
return {
success: true,
data: {
success: false,
error: 'Path exists but is not a git worktree or repository (and is not empty)',
},
};
}
}
}
if (pathExists) {
// Verify it belongs to the same repo
const gitCommonDirResult = await execGitRemote(
['rev-parse', '--git-common-dir'],
{ sshRemote, remoteCwd: worktreePath }
);
const mainGitDirResult = await execGitRemote(
['rev-parse', '--git-dir'],
{ sshRemote, remoteCwd: mainRepoCwd }
);
if (gitCommonDirResult.exitCode === 0 && mainGitDirResult.exitCode === 0) {
// Compare normalized paths on remote
const compareResult = await execRemoteShellCommand(
`test "$(cd '${worktreePath}' && cd '${gitCommonDirResult.stdout.trim()}' && pwd)" = "$(cd '${mainRepoCwd}' && cd '${mainGitDirResult.stdout.trim()}' && pwd)" && echo "SAME" || echo "DIFFERENT"`,
sshRemote
);
if (compareResult.stdout.trim() === 'DIFFERENT') {
return {
success: true,
data: {
success: false,
error: 'Worktree path belongs to a different repository',
},
};
}
}
// Get current branch in existing worktree
const currentBranchResult = await execGitRemote(
['rev-parse', '--abbrev-ref', 'HEAD'],
{ sshRemote, remoteCwd: worktreePath }
);
const currentBranch = currentBranchResult.exitCode === 0
? currentBranchResult.stdout.trim()
: '';
return {
success: true,
data: {
success: true,
created: false,
currentBranch,
requestedBranch: branchName,
branchMismatch: currentBranch !== branchName && branchName !== '',
},
};
}
// Worktree doesn't exist, create it
// First check if branch exists
const branchExistsResult = await execGitRemote(
['rev-parse', '--verify', branchName],
{ sshRemote, remoteCwd: mainRepoCwd }
);
const branchExists = branchExistsResult.exitCode === 0;
let createResult: ExecResult;
if (branchExists) {
createResult = await execGitRemote(
['worktree', 'add', worktreePath, branchName],
{ sshRemote, remoteCwd: mainRepoCwd }
);
} else {
createResult = await execGitRemote(
['worktree', 'add', '-b', branchName, worktreePath],
{ sshRemote, remoteCwd: mainRepoCwd }
);
}
if (createResult.exitCode !== 0) {
return {
success: true,
data: {
success: false,
error: createResult.stderr || 'Failed to create worktree',
},
};
}
return {
success: true,
data: {
success: true,
created: true,
currentBranch: branchName,
requestedBranch: branchName,
branchMismatch: false,
},
};
}
/**
* Worktree checkout result.
*/
export interface RemoteWorktreeCheckoutResult extends Record<string, unknown> {
success: boolean;
hasUncommittedChanges: boolean;
error?: string;
}
/**
* Checkout a branch in a worktree on a remote host.
*
* @param worktreePath Path to the worktree on the remote
* @param branchName Branch to checkout
* @param createIfMissing Whether to create the branch if it doesn't exist
* @param sshRemote SSH remote configuration
* @returns Checkout result
*/
export async function worktreeCheckoutRemote(
worktreePath: string,
branchName: string,
createIfMissing: boolean,
sshRemote: SshRemoteConfig
): Promise<RemoteGitResult<RemoteWorktreeCheckoutResult>> {
// Check for uncommitted changes
const statusResult = await execGitRemote(
['status', '--porcelain'],
{ sshRemote, remoteCwd: worktreePath }
);
if (statusResult.exitCode !== 0) {
return {
success: true,
data: {
success: false,
hasUncommittedChanges: false,
error: 'Failed to check git status',
},
};
}
if (statusResult.stdout.trim().length > 0) {
return {
success: true,
data: {
success: false,
hasUncommittedChanges: true,
error: 'Worktree has uncommitted changes. Please commit or stash them first.',
},
};
}
// Check if branch exists
const branchExistsResult = await execGitRemote(
['rev-parse', '--verify', branchName],
{ sshRemote, remoteCwd: worktreePath }
);
const branchExists = branchExistsResult.exitCode === 0;
let checkoutResult: ExecResult;
if (branchExists) {
checkoutResult = await execGitRemote(
['checkout', branchName],
{ sshRemote, remoteCwd: worktreePath }
);
} else if (createIfMissing) {
checkoutResult = await execGitRemote(
['checkout', '-b', branchName],
{ sshRemote, remoteCwd: worktreePath }
);
} else {
return {
success: true,
data: {
success: false,
hasUncommittedChanges: false,
error: `Branch '${branchName}' does not exist`,
},
};
}
if (checkoutResult.exitCode !== 0) {
return {
success: true,
data: {
success: false,
hasUncommittedChanges: false,
error: checkoutResult.stderr || 'Checkout failed',
},
};
}
return {
success: true,
data: {
success: true,
hasUncommittedChanges: false,
},
};
}
/**
* Worktree entry from list.
*/
export interface RemoteWorktreeEntry extends Record<string, unknown> {
path: string;
head: string;
branch: string | null;
isBare: boolean;
}
/**
* List all worktrees for a git repository on a remote host.
*
* @param cwd Path to the repository on the remote
* @param sshRemote SSH remote configuration
* @returns Array of worktree entries
*/
export async function listWorktreesRemote(
cwd: string,
sshRemote: SshRemoteConfig
): Promise<RemoteGitResult<RemoteWorktreeEntry[]>> {
const result = await execGitRemote(
['worktree', 'list', '--porcelain'],
{ sshRemote, remoteCwd: cwd }
);
if (result.exitCode !== 0) {
// Not a git repo or no worktree support
return {
success: true,
data: [],
};
}
// Parse porcelain output
const worktrees: RemoteWorktreeEntry[] = [];
const lines = result.stdout.split('\n');
let current: { path?: string; head?: string; branch?: string | null; isBare?: boolean } = {};
for (const line of lines) {
if (line.startsWith('worktree ')) {
current.path = line.substring(9);
} else if (line.startsWith('HEAD ')) {
current.head = line.substring(5);
} else if (line.startsWith('branch ')) {
const branchRef = line.substring(7);
current.branch = branchRef.replace('refs/heads/', '');
} else if (line === 'bare') {
current.isBare = true;
} else if (line === 'detached') {
current.branch = null;
} else if (line === '' && current.path) {
worktrees.push({
path: current.path,
head: current.head || '',
branch: current.branch ?? null,
isBare: current.isBare || false,
});
current = {};
}
}
// Handle last entry if no trailing newline
if (current.path) {
worktrees.push({
path: current.path,
head: current.head || '',
branch: current.branch ?? null,
isBare: current.isBare || false,
});
}
return {
success: true,
data: worktrees,
};
}
/**
* Get the repository root on a remote host.
*
* @param cwd Path to check on the remote
* @param sshRemote SSH remote configuration
* @returns Repository root path
*/
export async function getRepoRootRemote(
cwd: string,
sshRemote: SshRemoteConfig
): Promise<RemoteGitResult<string>> {
const result = await execGitRemote(
['rev-parse', '--show-toplevel'],
{ sshRemote, remoteCwd: cwd }
);
if (result.exitCode !== 0) {
return {
success: false,
error: result.stderr || 'Not a git repository',
};
}
return {
success: true,
data: result.stdout.trim(),
};
}

View File

@@ -2348,7 +2348,8 @@ function MaestroConsoleInner() {
});
// Handle SSH remote status events - tracks when sessions are executing on remote hosts
const unsubscribeSshRemote = window.maestro.process.onSshRemote?.((sessionId: string, sshRemote: { id: string; name: string; host: string } | null) => {
// Also populates session-wide SSH context (sshRemoteId, remoteCwd) for file explorer, git, auto run, etc.
const unsubscribeSshRemote = window.maestro.process.onSshRemote?.((sessionId: string, sshRemote: { id: string; name: string; host: string; remoteWorkingDir?: string } | null) => {
// Parse sessionId to get actual session ID (format: {id}-ai-{tabId} or {id}-terminal)
let actualSessionId: string;
const aiTabMatch = sessionId.match(/^(.+)-ai-(.+)$/);
@@ -2360,14 +2361,20 @@ function MaestroConsoleInner() {
actualSessionId = sessionId;
}
// Update session with SSH remote info
// Update session with SSH remote info and session-wide SSH context
setSessions(prev => prev.map(s => {
if (s.id !== actualSessionId) return s;
// Only update if the value actually changed (avoid unnecessary re-renders)
const currentRemoteId = s.sshRemote?.id;
const newRemoteId = sshRemote?.id;
if (currentRemoteId === newRemoteId) return s;
return { ...s, sshRemote: sshRemote ?? undefined };
return {
...s,
sshRemote: sshRemote ?? undefined,
// Populate session-wide SSH context for all operations (file explorer, git, auto run, etc.)
sshRemoteId: sshRemote?.id,
remoteCwd: sshRemote?.remoteWorkingDir,
};
}));
});
@@ -8773,6 +8780,7 @@ function MaestroConsoleInner() {
defaultShowExternalLinks={documentGraphShowExternalLinks}
onExternalLinksChange={settings.setDocumentGraphShowExternalLinks}
defaultMaxNodes={documentGraphMaxNodes}
sshRemoteId={activeSession?.sshRemoteId}
/>
{/* NOTE: All modals are now rendered via the unified <AppModals /> component above */}

View File

@@ -22,6 +22,9 @@ interface AutoRunProps {
theme: Theme;
sessionId: string; // Maestro session ID for per-session attachment storage
// SSH Remote context (for remote sessions)
sshRemoteId?: string; // SSH remote config ID - when set, all fs/autorun operations use SSH
// Folder & document state
folderPath: string | null;
selectedFile: string | null;
@@ -103,12 +106,14 @@ function AttachmentImage({
src,
alt,
folderPath,
sshRemoteId,
theme,
onImageClick
}: {
src?: string;
alt?: string;
folderPath: string | null;
sshRemoteId?: string; // SSH remote ID for loading images from remote sessions
theme: any;
onImageClick?: (filename: string) => void;
}) {
@@ -141,7 +146,7 @@ function AttachmentImage({
// Load from folder using absolute path
const absolutePath = `${folderPath}/${decodedSrc}`;
window.maestro.fs.readFile(absolutePath)
window.maestro.fs.readFile(absolutePath, sshRemoteId)
.then((result) => {
if (result.startsWith('data:')) {
imageCache.set(cacheKey, result);
@@ -168,7 +173,7 @@ function AttachmentImage({
} else if (src.startsWith('/')) {
// Absolute file path - load via IPC
setFilename(src.split('/').pop() || null);
window.maestro.fs.readFile(src)
window.maestro.fs.readFile(src, sshRemoteId)
.then((result) => {
if (result.startsWith('data:')) {
setDataUrl(result);
@@ -185,7 +190,7 @@ function AttachmentImage({
// Other relative path - try to load as file from folderPath if available
setFilename(src.split('/').pop() || null);
const pathToLoad = folderPath ? `${folderPath}/${src}` : src;
window.maestro.fs.readFile(pathToLoad)
window.maestro.fs.readFile(pathToLoad, sshRemoteId)
.then((result) => {
if (result.startsWith('data:')) {
setDataUrl(result);
@@ -199,7 +204,7 @@ function AttachmentImage({
setLoading(false);
});
}
}, [src, folderPath]);
}, [src, folderPath, sshRemoteId]);
if (loading) {
return (
@@ -317,6 +322,7 @@ function ImagePreview({
const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInner({
theme,
sessionId,
sshRemoteId,
folderPath,
selectedFile,
documentList,
@@ -487,12 +493,12 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
if (!folderPath || !selectedFile || !isDirty) return;
try {
await window.maestro.autorun.writeDoc(folderPath, selectedFile + '.md', localContent);
await window.maestro.autorun.writeDoc(folderPath, selectedFile + '.md', localContent, sshRemoteId);
setSavedContent(localContent);
} catch (err) {
console.error('Failed to save:', err);
}
}, [folderPath, selectedFile, localContent, isDirty, setSavedContent]);
}, [folderPath, selectedFile, localContent, isDirty, setSavedContent, sshRemoteId]);
// Revert function - discard changes
const handleRevert = useCallback(() => {
@@ -569,12 +575,12 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
// Auto-save the reset content
try {
await window.maestro.autorun.writeDoc(folderPath, selectedFile + '.md', resetContent);
await window.maestro.autorun.writeDoc(folderPath, selectedFile + '.md', resetContent, sshRemoteId);
setSavedContent(resetContent);
} catch (err) {
console.error('Failed to save after reset:', err);
}
}, [folderPath, selectedFile, localContent, setLocalContent, setSavedContent, pushUndoState, lastUndoSnapshotRef]);
}, [folderPath, selectedFile, localContent, setLocalContent, setSavedContent, pushUndoState, lastUndoSnapshotRef, sshRemoteId]);
// Image handling hook (attachments, paste, upload, lightbox)
const {
@@ -1225,13 +1231,14 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
src={src}
alt={alt}
folderPath={folderPath}
sshRemoteId={sshRemoteId}
theme={theme}
onImageClick={openLightboxByFilename}
{...props}
/>
),
};
}, [theme, folderPath, openLightboxByFilename, handleFileClick]);
}, [theme, folderPath, sshRemoteId, openLightboxByFilename, handleFileClick]);
// Search-highlighted components - only used in preview mode with active search
// This allows the base components to remain stable during editing
@@ -1263,13 +1270,14 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
src={src}
alt={alt}
folderPath={folderPath}
sshRemoteId={sshRemoteId}
theme={theme}
onImageClick={openLightboxByFilename}
{...props}
/>
),
};
}, [theme, folderPath, openLightboxByFilename, handleFileClick, searchOpen, searchQuery, totalMatches, currentMatchIndex, handleMatchRendered]);
}, [theme, folderPath, sshRemoteId, openLightboxByFilename, handleFileClick, searchOpen, searchQuery, totalMatches, currentMatchIndex, handleMatchRendered]);
// Use search-highlighted components when available, otherwise use base components
const markdownComponents = searchHighlightedComponents || baseMarkdownComponents;

View File

@@ -13,6 +13,7 @@ interface AutoRunExpandedModalProps {
onClose: () => void;
// Pass through all AutoRun props
sessionId: string;
sshRemoteId?: string; // SSH remote config ID - when set, all fs/autorun operations use SSH
folderPath: string | null;
selectedFile: string | null;
documentList: string[];

View File

@@ -76,6 +76,8 @@ export interface DocumentGraphViewProps {
defaultNeighborDepth?: number;
/** Callback to persist neighbor depth changes */
onNeighborDepthChange?: (depth: number) => void;
/** Optional SSH remote ID - if provided, shows unavailable message (can't scan remote filesystem) */
sshRemoteId?: string;
}
/**
@@ -95,6 +97,7 @@ export function DocumentGraphView({
defaultMaxNodes = DEFAULT_MAX_NODES,
defaultNeighborDepth = 2,
onNeighborDepthChange,
sshRemoteId,
}: DocumentGraphViewProps) {
// Graph data state
const [nodes, setNodes] = useState<ForceGraphNode[]>([]);
@@ -494,6 +497,96 @@ export function DocumentGraphView({
if (!isOpen) return null;
// Show unavailable message for remote sessions - Document Graph cannot scan remote filesystems
if (sshRemoteId) {
return (
<div
className="fixed inset-0 modal-overlay flex items-center justify-center z-[9999] animate-in fade-in duration-100"
onClick={onClose}
>
<div
ref={containerRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-label="Document Graph - Not Available"
className="rounded-xl shadow-2xl border overflow-hidden flex flex-col outline-none"
style={{
backgroundColor: theme.colors.bgActivity,
borderColor: theme.colors.border,
width: 480,
maxWidth: '90vw',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="px-6 py-4 border-b flex items-center justify-between flex-shrink-0"
style={{ borderColor: theme.colors.border }}
>
<div className="flex items-center gap-3">
<Network className="w-5 h-5" style={{ color: theme.colors.textDim }} />
<h2 className="text-lg font-semibold" style={{ color: theme.colors.textMain }}>
Document Graph
</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded transition-colors"
style={{ color: theme.colors.textDim }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = `${theme.colors.accent}20`)}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
title="Close (Esc)"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div className="px-6 py-8 flex flex-col items-center gap-4">
<div
className="p-4 rounded-full"
style={{ backgroundColor: `${theme.colors.accent}20` }}
>
<AlertCircle
className="w-8 h-8"
style={{ color: theme.colors.accent }}
/>
</div>
<div className="text-center">
<h3 className="text-base font-medium mb-2" style={{ color: theme.colors.textMain }}>
Not Available for Remote Sessions
</h3>
<p className="text-sm" style={{ color: theme.colors.textDim }}>
Document Graph requires local filesystem access to scan and visualize markdown file relationships.
This feature is not available when connected to a remote host via SSH.
</p>
</div>
</div>
{/* Footer */}
<div
className="px-6 py-4 border-t flex justify-end"
style={{ borderColor: theme.colors.border }}
>
<button
onClick={onClose}
className="px-4 py-2 rounded text-sm font-medium transition-colors"
style={{
backgroundColor: theme.colors.accent,
color: theme.colors.bgMain,
}}
onMouseEnter={(e) => (e.currentTarget.style.opacity = '0.85')}
onMouseLeave={(e) => (e.currentTarget.style.opacity = '1')}
>
Close
</button>
</div>
</div>
</div>
);
}
const documentCount = nodes.filter(n => n.nodeType === 'document').length;
const externalCount = nodes.filter(n => n.nodeType === 'external').length;

View File

@@ -265,6 +265,7 @@ export const RightPanel = memo(forwardRef<RightPanelHandle, RightPanelProps>(fun
const autoRunSharedProps = {
theme,
sessionId: session.id,
sshRemoteId: session.sshRemoteId,
folderPath: session.autoRunFolderPath || null,
selectedFile: session.autoRunSelectedFile || null,
documentList: autoRunDocumentList,

View File

@@ -291,7 +291,8 @@ interface MaestroAPI {
error?: string;
}>;
// Git worktree operations for Auto Run parallelization
worktreeInfo: (worktreePath: string) => Promise<{
// All worktree operations support SSH remote execution via optional sshRemoteId parameter
worktreeInfo: (worktreePath: string, sshRemoteId?: string) => Promise<{
success: boolean;
exists?: boolean;
isWorktree?: boolean;
@@ -299,12 +300,12 @@ interface MaestroAPI {
repoRoot?: string;
error?: string;
}>;
getRepoRoot: (cwd: string) => Promise<{
getRepoRoot: (cwd: string, sshRemoteId?: string) => Promise<{
success: boolean;
root?: string;
error?: string;
}>;
worktreeSetup: (mainRepoCwd: string, worktreePath: string, branchName: string) => Promise<{
worktreeSetup: (mainRepoCwd: string, worktreePath: string, branchName: string, sshRemoteId?: string) => Promise<{
success: boolean;
created?: boolean;
currentBranch?: string;
@@ -312,7 +313,7 @@ interface MaestroAPI {
branchMismatch?: boolean;
error?: string;
}>;
worktreeCheckout: (worktreePath: string, branchName: string, createIfMissing: boolean) => Promise<{
worktreeCheckout: (worktreePath: string, branchName: string, createIfMissing: boolean, sshRemoteId?: string) => Promise<{
success: boolean;
hasUncommittedChanges: boolean;
error?: string;
@@ -331,7 +332,8 @@ interface MaestroAPI {
installed: boolean;
authenticated: boolean;
}>;
listWorktrees: (cwd: string) => Promise<{
// Supports SSH remote execution via optional sshRemoteId parameter
listWorktrees: (cwd: string, sshRemoteId?: string) => Promise<{
worktrees: Array<{
path: string;
head: string;
@@ -348,9 +350,13 @@ interface MaestroAPI {
repoRoot: string | null;
}>;
}>;
watchWorktreeDirectory: (sessionId: string, worktreePath: string) => Promise<{
// File watching is not available for SSH remote sessions.
// For remote sessions, returns isRemote: true indicating polling should be used instead.
watchWorktreeDirectory: (sessionId: string, worktreePath: string, sshRemoteId?: string) => Promise<{
success: boolean;
error?: string;
isRemote?: boolean;
message?: string;
}>;
unwatchWorktreeDirectory: (sessionId: string) => Promise<{
success: boolean;
@@ -364,17 +370,17 @@ interface MaestroAPI {
};
fs: {
homeDir: () => Promise<string>;
readDir: (dirPath: string) => Promise<DirectoryEntry[]>;
readFile: (filePath: string) => Promise<string>;
readDir: (dirPath: string, sshRemoteId?: string) => Promise<DirectoryEntry[]>;
readFile: (filePath: string, sshRemoteId?: string) => Promise<string>;
writeFile: (filePath: string, content: string) => Promise<{ success: boolean }>;
stat: (filePath: string) => Promise<{
stat: (filePath: string, sshRemoteId?: string) => Promise<{
size: number;
createdAt: string;
modifiedAt: string;
isDirectory: boolean;
isFile: boolean;
}>;
directorySize: (dirPath: string) => Promise<{
directorySize: (dirPath: string, sshRemoteId?: string) => Promise<{
totalSize: number;
fileCount: number;
folderCount: number;
@@ -866,21 +872,23 @@ interface MaestroAPI {
getPath: (sessionId: string) => Promise<{ success: boolean; path: string }>;
};
// Auto Run file operations
// SSH remote support: Core operations accept optional sshRemoteId for remote file operations
autorun: {
listDocs: (folderPath: string) => Promise<{
listDocs: (folderPath: string, sshRemoteId?: string) => Promise<{
success: boolean;
files: string[];
tree?: AutoRunTreeNode[];
error?: string;
}>;
readDoc: (folderPath: string, filename: string) => Promise<{ success: boolean; content?: string; error?: string }>;
writeDoc: (folderPath: string, filename: string, content: string) => Promise<{ success: boolean; error?: string }>;
readDoc: (folderPath: string, filename: string, sshRemoteId?: string) => Promise<{ success: boolean; content?: string; error?: string }>;
writeDoc: (folderPath: string, filename: string, content: string, sshRemoteId?: string) => Promise<{ success: boolean; error?: string }>;
saveImage: (folderPath: string, docName: string, base64Data: string, extension: string) => Promise<{ success: boolean; relativePath?: string; error?: string }>;
deleteImage: (folderPath: string, relativePath: string) => Promise<{ success: boolean; error?: string }>;
listImages: (folderPath: string, docName: string) => Promise<{ success: boolean; images?: Array<{ filename: string; relativePath: string }>; error?: string }>;
deleteFolder: (projectPath: string) => Promise<{ success: boolean; error?: string }>;
// File watching for live updates
watchFolder: (folderPath: string) => Promise<{ success: boolean; error?: string }>;
// For remote sessions (sshRemoteId provided), returns isRemote: true indicating polling should be used
watchFolder: (folderPath: string, sshRemoteId?: string) => Promise<{ success: boolean; isRemote?: boolean; message?: string; error?: string }>;
unwatchFolder: (folderPath: string) => Promise<{ success: boolean; error?: string }>;
onFileChanged: (handler: (data: { folderPath: string; filename: string; eventType: string }) => void) => () => void;
// Backup operations for reset-on-completion documents (legacy)

View File

@@ -401,7 +401,9 @@ export function useBatchProcessor({
delete errorResolutionRefs.current[sessionId];
// Set up worktree if enabled using extracted hook
const worktreeResult = await worktreeManager.setupWorktree(session.cwd, worktree);
// Inject sshRemoteId from session into worktree config for remote worktree operations
const worktreeWithSsh = worktree ? { ...worktree, sshRemoteId: session.sshRemoteId } : undefined;
const worktreeResult = await worktreeManager.setupWorktree(session.cwd, worktreeWithSsh);
if (!worktreeResult.success) {
window.maestro.logger.log('error', 'Worktree setup failed', 'BatchProcessor', { sessionId, error: worktreeResult.error });
return;

View File

@@ -26,6 +26,8 @@ export interface WorktreeConfig {
prTargetBranch?: string;
/** Path to gh CLI binary (if not in PATH) */
ghPath?: string;
/** SSH remote ID for remote sessions (optional) */
sshRemoteId?: string;
}
/**
@@ -167,7 +169,8 @@ ${docList}
const setupResult = await window.maestro.git.worktreeSetup(
sessionCwd,
worktree.path,
worktree.branchName
worktree.branchName,
worktree.sshRemoteId
);
window.maestro.logger.log('info', 'worktreeSetup result', 'WorktreeManager', {
@@ -205,7 +208,8 @@ ${docList}
const checkoutResult = await window.maestro.git.worktreeCheckout(
worktree.path,
worktree.branchName,
true // createIfMissing
true, // createIfMissing
worktree.sshRemoteId
);
window.maestro.logger.log('info', 'worktreeCheckout result', 'WorktreeManager', {

View File

@@ -38,6 +38,8 @@ export interface UseWorktreeValidationDeps {
worktreeEnabled: boolean;
/** The session's current working directory (main repo) */
sessionCwd: string;
/** SSH remote ID for remote sessions (optional) */
sshRemoteId?: string;
}
/**
@@ -90,6 +92,7 @@ export function useWorktreeValidation({
branchName,
worktreeEnabled,
sessionCwd,
sshRemoteId,
}: UseWorktreeValidationDeps): UseWorktreeValidationReturn {
const [validation, setValidation] = useState<WorktreeValidationState>(INITIAL_VALIDATION_STATE);
@@ -108,7 +111,7 @@ export function useWorktreeValidation({
const timeoutId = setTimeout(async () => {
try {
// Check if the path exists and get worktree info
const worktreeInfoResult = await window.maestro.git.worktreeInfo(worktreePath);
const worktreeInfoResult = await window.maestro.git.worktreeInfo(worktreePath, sshRemoteId);
if (!worktreeInfoResult.success) {
setValidation({
@@ -138,7 +141,7 @@ export function useWorktreeValidation({
// Path exists - check if it's part of the same repo
// If there's no repoRoot, the directory exists but isn't a git repo - that's fine for a new worktree
const mainRepoRootResult = await window.maestro.git.getRepoRoot(sessionCwd);
const mainRepoRootResult = await window.maestro.git.getRepoRoot(sessionCwd, sshRemoteId);
const sameRepo =
!worktreeInfoResult.repoRoot ||
(mainRepoRootResult.success && worktreeInfoResult.repoRoot === mainRepoRootResult.root);
@@ -156,7 +159,8 @@ export function useWorktreeValidation({
if (branchMismatch && sameRepo) {
try {
// Use git status to check for uncommitted changes in the worktree
const statusResult = await window.maestro.git.status(worktreePath);
// Pass sshRemoteId to support remote worktree validation
const statusResult = await window.maestro.git.status(worktreePath, sshRemoteId);
hasChanges = hasUncommittedChanges(statusResult.stdout);
} catch {
// If we can't check, assume no uncommitted changes
@@ -190,7 +194,7 @@ export function useWorktreeValidation({
// Cleanup timeout on unmount or when dependencies change
return () => clearTimeout(timeoutId);
}, [worktreePath, branchName, worktreeEnabled, sessionCwd]);
}, [worktreePath, branchName, worktreeEnabled, sessionCwd, sshRemoteId]);
return { validation };
}

View File

@@ -2,11 +2,26 @@ import { useCallback, useEffect, useMemo } from 'react';
import type { RightPanelHandle } from '../../components/RightPanel';
import type { Session } from '../../types';
import type { FileNode } from '../../types/fileTree';
import { loadFileTree, compareFileTrees, type FileTreeChanges } from '../../utils/fileExplorer';
import { loadFileTree, compareFileTrees, type FileTreeChanges, type SshContext } from '../../utils/fileExplorer';
import { fuzzyMatch } from '../../utils/search';
import { gitService } from '../../services/git';
/**
* Extract SSH context from session for remote file operations.
* Returns undefined if no SSH remote is configured.
*/
function getSshContext(session: Session): SshContext | undefined {
if (!session.sshRemoteId) {
return undefined;
}
return {
sshRemoteId: session.sshRemoteId,
remoteCwd: session.remoteCwd,
};
}
export type { RightPanelHandle } from '../../components/RightPanel';
export type { SshContext } from '../../utils/fileExplorer';
/**
* Dependencies for the useFileTreeManagement hook.
@@ -68,17 +83,22 @@ export function useFileTreeManagement(
/**
* Refresh file tree for a session and return the changes detected.
* Uses sessionsRef to avoid dependency on sessions state (prevents timer reset on every session change).
* Passes SSH context for remote sessions to enable remote file operations (Phase 2+).
*/
const refreshFileTree = useCallback(async (sessionId: string): Promise<FileTreeChanges | undefined> => {
// Use sessionsRef to avoid dependency on sessions state (prevents timer reset on every session change)
const session = sessionsRef.current.find(s => s.id === sessionId);
if (!session) return undefined;
// Extract SSH context for remote file operations
const sshContext = getSshContext(session);
try {
// Fetch tree and stats in parallel
// Pass SSH context for remote file operations
const [newTree, stats] = await Promise.all([
loadFileTree(session.cwd),
window.maestro.fs.directorySize(session.cwd)
loadFileTree(session.cwd, 10, 0, sshContext),
window.maestro.fs.directorySize(session.cwd, sshContext?.sshRemoteId)
]);
const oldTree = session.fileTree || [];
const changes = compareFileTrees(oldTree, newTree);
@@ -115,6 +135,7 @@ export function useFileTreeManagement(
/**
* Refresh both file tree and git state for a session.
* Loads file tree, checks git repo status, and fetches branches/tags if applicable.
* Passes SSH context for remote sessions to enable remote operations (Phase 2+).
*/
const refreshGitFileState = useCallback(async (sessionId: string) => {
const session = sessions.find(s => s.id === sessionId);
@@ -122,11 +143,15 @@ export function useFileTreeManagement(
const cwd = session.inputMode === 'terminal' ? (session.shellCwd || session.cwd) : session.cwd;
// Extract SSH context for remote file/git operations
const sshContext = getSshContext(session);
try {
// Refresh file tree, stats, git repo status, branches, and tags in parallel
// Pass SSH context for remote file operations
const [tree, stats, isGitRepo] = await Promise.all([
loadFileTree(cwd),
window.maestro.fs.directorySize(cwd),
loadFileTree(cwd, 10, 0, sshContext),
window.maestro.fs.directorySize(cwd, sshContext?.sshRemoteId),
gitService.isRepo(cwd)
]);
@@ -179,6 +204,7 @@ export function useFileTreeManagement(
/**
* Load file tree when active session changes.
* Only loads if file tree is empty.
* Passes SSH context for remote sessions to enable remote operations (Phase 2+).
*/
useEffect(() => {
const session = sessions.find(s => s.id === activeSessionId);
@@ -186,9 +212,12 @@ export function useFileTreeManagement(
// Only load if file tree is empty
if (!session.fileTree || session.fileTree.length === 0) {
// Extract SSH context for remote file operations
const sshContext = getSshContext(session);
Promise.all([
loadFileTree(session.cwd),
window.maestro.fs.directorySize(session.cwd)
loadFileTree(session.cwd, 10, 0, sshContext),
window.maestro.fs.directorySize(session.cwd, sshContext?.sshRemoteId)
]).then(([tree, stats]) => {
setSessions(prev => prev.map(s =>
s.id === activeSessionId ? {

View File

@@ -167,9 +167,12 @@ export function useGitStatusPolling(
const isActiveSession = session.id === currentActiveSessionId;
// Get SSH remote ID from session for remote git operations
const sshRemoteId = session.sshRemoteId;
// For non-active sessions, just get basic status (file count)
if (!isActiveSession) {
const status = await gitService.getStatus(cwd);
const status = await gitService.getStatus(cwd, sshRemoteId);
const statusData: GitStatusData = {
fileCount: status.files.length,
branch: status.branch,
@@ -187,9 +190,9 @@ export function useGitStatusPolling(
// Use git:info for branch/remote/ahead/behind (single IPC call, 4 parallel git commands)
// Plus get detailed file changes with numstat
const [gitInfo, status, numstat] = await Promise.all([
window.maestro.git.info(cwd),
gitService.getStatus(cwd),
gitService.getNumstat(cwd),
window.maestro.git.info(cwd, sshRemoteId),
gitService.getStatus(cwd, sshRemoteId),
gitService.getNumstat(cwd, sshRemoteId),
]);
// Create a map of path -> numstat data

View File

@@ -26,13 +26,19 @@ export interface GitNumstat {
}>;
}
/**
* All git service methods support SSH remote execution via optional sshRemoteId parameter.
* When sshRemoteId is provided, operations execute on the remote host via SSH.
*/
export const gitService = {
/**
* Check if a directory is a git repository
* @param cwd Working directory path
* @param sshRemoteId Optional SSH remote ID for remote execution
*/
async isRepo(cwd: string): Promise<boolean> {
async isRepo(cwd: string, sshRemoteId?: string): Promise<boolean> {
return createIpcMethod({
call: () => window.maestro.git.isRepo(cwd),
call: () => window.maestro.git.isRepo(cwd, sshRemoteId),
errorContext: 'Git isRepo',
defaultValue: false,
});
@@ -40,13 +46,15 @@ export const gitService = {
/**
* Get git status (porcelain format) and current branch
* @param cwd Working directory path
* @param sshRemoteId Optional SSH remote ID for remote execution
*/
async getStatus(cwd: string): Promise<GitStatus> {
async getStatus(cwd: string, sshRemoteId?: string): Promise<GitStatus> {
return createIpcMethod({
call: async () => {
const [statusResult, branchResult] = await Promise.all([
window.maestro.git.status(cwd),
window.maestro.git.branch(cwd),
window.maestro.git.status(cwd, sshRemoteId),
window.maestro.git.branch(cwd, sshRemoteId),
]);
const files = parseGitStatusPorcelain(statusResult.stdout || '');
@@ -61,18 +69,21 @@ export const gitService = {
/**
* Get git diff for specific files or all changes
* @param cwd Working directory path
* @param files Optional list of files to get diff for
* @param sshRemoteId Optional SSH remote ID for remote execution
*/
async getDiff(cwd: string, files?: string[]): Promise<GitDiff> {
async getDiff(cwd: string, files?: string[], sshRemoteId?: string): Promise<GitDiff> {
return createIpcMethod({
call: async () => {
// If no files specified, get full diff
if (!files || files.length === 0) {
const result = await window.maestro.git.diff(cwd);
const result = await window.maestro.git.diff(cwd, undefined, sshRemoteId);
return { diff: result.stdout };
}
// Otherwise get diff for specific files
const results = await Promise.all(
files.map(file => window.maestro.git.diff(cwd, file))
files.map(file => window.maestro.git.diff(cwd, file, sshRemoteId))
);
return { diff: results.map(result => result.stdout).join('\n') };
},
@@ -83,11 +94,13 @@ export const gitService = {
/**
* Get line-level statistics for all changes
* @param cwd Working directory path
* @param sshRemoteId Optional SSH remote ID for remote execution
*/
async getNumstat(cwd: string): Promise<GitNumstat> {
async getNumstat(cwd: string, sshRemoteId?: string): Promise<GitNumstat> {
return createIpcMethod({
call: async () => {
const result = await window.maestro.git.numstat(cwd);
const result = await window.maestro.git.numstat(cwd, sshRemoteId);
const files = parseGitNumstat(result.stdout || '');
return { files };
},
@@ -99,11 +112,13 @@ export const gitService = {
/**
* Get the browser-friendly URL for the remote repository
* Returns null if no remote or URL cannot be parsed
* @param cwd Working directory path
* @param sshRemoteId Optional SSH remote ID for remote execution
*/
async getRemoteBrowserUrl(cwd: string): Promise<string | null> {
async getRemoteBrowserUrl(cwd: string, sshRemoteId?: string): Promise<string | null> {
return createIpcMethod({
call: async () => {
const result = await window.maestro.git.remote(cwd);
const result = await window.maestro.git.remote(cwd, sshRemoteId);
return result.stdout ? remoteUrlToBrowserUrl(result.stdout) : null;
},
errorContext: 'Git remote',
@@ -113,11 +128,13 @@ export const gitService = {
/**
* Get all branches (local and remote, deduplicated)
* @param cwd Working directory path
* @param sshRemoteId Optional SSH remote ID for remote execution
*/
async getBranches(cwd: string): Promise<string[]> {
async getBranches(cwd: string, sshRemoteId?: string): Promise<string[]> {
return createIpcMethod({
call: async () => {
const result = await window.maestro.git.branches(cwd);
const result = await window.maestro.git.branches(cwd, sshRemoteId);
return result.branches || [];
},
errorContext: 'Git branches',
@@ -127,11 +144,13 @@ export const gitService = {
/**
* Get all tags
* @param cwd Working directory path
* @param sshRemoteId Optional SSH remote ID for remote execution
*/
async getTags(cwd: string): Promise<string[]> {
async getTags(cwd: string, sshRemoteId?: string): Promise<string[]> {
return createIpcMethod({
call: async () => {
const result = await window.maestro.git.tags(cwd);
const result = await window.maestro.git.tags(cwd, sshRemoteId);
return result.tags || [];
},
errorContext: 'Git tags',

View File

@@ -477,6 +477,10 @@ export interface Session {
host: string; // Remote host for tooltip
};
// SSH Remote context (session-wide, for all operations - file explorer, git, auto run, etc.)
sshRemoteId?: string; // ID of SSH remote config being used (flattened from sshRemote.id)
remoteCwd?: string; // Current working directory on remote host
// Per-session agent configuration overrides
// These override the global agent-level settings for this specific session
customPath?: string; // Custom path to agent binary (overrides agent-level)

View File

@@ -50,18 +50,33 @@ export interface FileTreeNode {
children?: FileTreeNode[];
}
/**
* SSH context for remote file operations
*/
export interface SshContext {
/** SSH remote config ID */
sshRemoteId?: string;
/** Remote working directory */
remoteCwd?: string;
}
/**
* Load file tree from directory recursively
* @param dirPath - The directory path to load
* @param maxDepth - Maximum recursion depth (default: 10)
* @param currentDepth - Current recursion depth (internal use)
* @param sshContext - Optional SSH context for remote file operations
*/
export async function loadFileTree(
dirPath: string,
maxDepth = 10,
currentDepth = 0
currentDepth = 0,
sshContext?: SshContext
): Promise<FileTreeNode[]> {
if (currentDepth >= maxDepth) return [];
try {
const entries = await window.maestro.fs.readDir(dirPath);
const entries = await window.maestro.fs.readDir(dirPath, sshContext?.sshRemoteId);
const tree: FileTreeNode[] = [];
for (const entry of entries) {
@@ -71,7 +86,7 @@ export async function loadFileTree(
}
if (entry.isDirectory) {
const children = await loadFileTree(`${dirPath}/${entry.name}`, maxDepth, currentDepth + 1);
const children = await loadFileTree(`${dirPath}/${entry.name}`, maxDepth, currentDepth + 1, sshContext);
tree.push({
name: entry.name,
type: 'folder',