Enhance SSH functionality and configuration management
- Added SSH config host alias support, allowing users to fetch and resolve host entries from the SSH config file. - Introduced SshConfigParser to handle parsing of ~/.ssh/config and retrieving host aliases. - Updated ConnectionSection to include a combobox for selecting SSH config hosts with auto-fill capabilities. - Enhanced SshConnectionManager to utilize the new SshConfigParser for resolving host configurations. - Added IPC channels for fetching SSH config hosts and resolving host aliases. - Updated relevant types and state management to accommodate new SSH config features.
This commit is contained in:
parent
4b56186f7c
commit
921420b946
15 changed files with 591 additions and 83 deletions
10
README.md
10
README.md
|
|
@ -1,5 +1,5 @@
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="resources/icons/png/512x512.png" alt="claude-devtools" width="120" />
|
<img src="resources/icons/png/1024x1024.png" alt="claude-devtools" width="120" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">claude-devtools</h1>
|
<h1 align="center">claude-devtools</h1>
|
||||||
|
|
@ -10,14 +10,6 @@
|
||||||
A desktop app that turns Claude Code's opaque session logs into a visual, searchable, actionable interface.
|
A desktop app that turns Claude Code's opaque session logs into a visual, searchable, actionable interface.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://github.com/matt1398/claude-devtools/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT" /></a>
|
|
||||||
<a href="#"><img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows-lightgrey.svg" alt="Platform" /></a>
|
|
||||||
<a href="#"><img src="https://img.shields.io/badge/electron-40-47848F.svg?logo=electron" alt="Electron" /></a>
|
|
||||||
<a href="#"><img src="https://img.shields.io/badge/react-18-61DAFB.svg?logo=react" alt="React" /></a>
|
|
||||||
<a href="#"><img src="https://img.shields.io/badge/typescript-5-3178C6.svg?logo=typescript" alt="TypeScript" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@main': resolve(__dirname, 'src/main'),
|
'@main': resolve(__dirname, 'src/main'),
|
||||||
'@shared': resolve(__dirname, 'src/shared')
|
'@shared': resolve(__dirname, 'src/shared'),
|
||||||
|
'@preload': resolve(__dirname, 'src/preload')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
|
"ssh-config": "^5.0.4",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"zustand": "^4.5.0"
|
"zustand": "^4.5.0"
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,9 @@ importers:
|
||||||
remark-parse:
|
remark-parse:
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.0.0
|
version: 11.0.0
|
||||||
|
ssh-config:
|
||||||
|
specifier: ^5.0.4
|
||||||
|
version: 5.0.4
|
||||||
ssh2:
|
ssh2:
|
||||||
specifier: ^1.17.0
|
specifier: ^1.17.0
|
||||||
version: 1.17.0
|
version: 1.17.0
|
||||||
|
|
@ -3551,6 +3554,9 @@ packages:
|
||||||
sprintf-js@1.1.3:
|
sprintf-js@1.1.3:
|
||||||
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
|
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
|
||||||
|
|
||||||
|
ssh-config@5.0.4:
|
||||||
|
resolution: {integrity: sha512-nCCJTY30Alhm8CWhhN8Yr1YAx2WOrDBLMMh7JYGrzCj3qssTPV+v10hYimd+8wJJeV10VrN8lFumawAEfEwjNA==}
|
||||||
|
|
||||||
ssh2@1.17.0:
|
ssh2@1.17.0:
|
||||||
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
|
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
|
||||||
engines: {node: '>=10.16.0'}
|
engines: {node: '>=10.16.0'}
|
||||||
|
|
@ -7931,6 +7937,8 @@ snapshots:
|
||||||
sprintf-js@1.1.3:
|
sprintf-js@1.1.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
ssh-config@5.0.4: {}
|
||||||
|
|
||||||
ssh2@1.17.0:
|
ssh2@1.17.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
asn1: 0.2.6
|
asn1: 0.2.6
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import {
|
||||||
NotificationManager,
|
NotificationManager,
|
||||||
ProjectScanner,
|
ProjectScanner,
|
||||||
SessionParser,
|
SessionParser,
|
||||||
|
SshConnectionManager,
|
||||||
SubagentResolver,
|
SubagentResolver,
|
||||||
UpdaterService,
|
UpdaterService,
|
||||||
} from './services';
|
} from './services';
|
||||||
|
|
@ -64,9 +65,7 @@ let dataCache: DataCache;
|
||||||
let fileWatcher: FileWatcher;
|
let fileWatcher: FileWatcher;
|
||||||
let notificationManager: NotificationManager;
|
let notificationManager: NotificationManager;
|
||||||
let updaterService: UpdaterService;
|
let updaterService: UpdaterService;
|
||||||
let sshConnectionManager: InstanceType<
|
let sshConnectionManager: SshConnectionManager;
|
||||||
typeof import('./services/infrastructure/SshConnectionManager').SshConnectionManager
|
|
||||||
>;
|
|
||||||
let cleanupInterval: NodeJS.Timeout | null = null;
|
let cleanupInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -76,11 +75,7 @@ function initializeServices(): void {
|
||||||
logger.info('Initializing services...');
|
logger.info('Initializing services...');
|
||||||
|
|
||||||
// Initialize SSH connection manager
|
// Initialize SSH connection manager
|
||||||
const { SshConnectionManager: SshConnMgr } =
|
sshConnectionManager = new SshConnectionManager();
|
||||||
require('./services/infrastructure/SshConnectionManager') as {
|
|
||||||
SshConnectionManager: typeof import('./services/infrastructure/SshConnectionManager').SshConnectionManager;
|
|
||||||
};
|
|
||||||
sshConnectionManager = new SshConnMgr();
|
|
||||||
|
|
||||||
// Initialize services (paths are set automatically from environment)
|
// Initialize services (paths are set automatically from environment)
|
||||||
projectScanner = new ProjectScanner();
|
projectScanner = new ProjectScanner();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@
|
||||||
import {
|
import {
|
||||||
SSH_CONNECT,
|
SSH_CONNECT,
|
||||||
SSH_DISCONNECT,
|
SSH_DISCONNECT,
|
||||||
|
SSH_GET_CONFIG_HOSTS,
|
||||||
SSH_GET_STATE,
|
SSH_GET_STATE,
|
||||||
|
SSH_RESOLVE_HOST,
|
||||||
SSH_TEST,
|
SSH_TEST,
|
||||||
} from '@preload/constants/ipcChannels';
|
} from '@preload/constants/ipcChannels';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
@ -96,6 +98,28 @@ export function registerSshHandlers(ipcMain: IpcMain): void {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(SSH_GET_CONFIG_HOSTS, async () => {
|
||||||
|
try {
|
||||||
|
const hosts = await connectionManager.getConfigHosts();
|
||||||
|
return { success: true, data: hosts };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.error('Failed to get SSH config hosts:', message);
|
||||||
|
return { success: true, data: [] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(SSH_RESOLVE_HOST, async (_event, alias: string) => {
|
||||||
|
try {
|
||||||
|
const entry = await connectionManager.resolveHostConfig(alias);
|
||||||
|
return { success: true, data: entry };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.error(`Failed to resolve SSH host "${alias}":`, message);
|
||||||
|
return { success: true, data: null };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
logger.info('SSH handlers registered');
|
logger.info('SSH handlers registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,4 +128,6 @@ export function removeSshHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(SSH_DISCONNECT);
|
ipcMain.removeHandler(SSH_DISCONNECT);
|
||||||
ipcMain.removeHandler(SSH_GET_STATE);
|
ipcMain.removeHandler(SSH_GET_STATE);
|
||||||
ipcMain.removeHandler(SSH_TEST);
|
ipcMain.removeHandler(SSH_TEST);
|
||||||
|
ipcMain.removeHandler(SSH_GET_CONFIG_HOSTS);
|
||||||
|
ipcMain.removeHandler(SSH_RESOLVE_HOST);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
194
src/main/services/infrastructure/SshConfigParser.ts
Normal file
194
src/main/services/infrastructure/SshConfigParser.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
/**
|
||||||
|
* SshConfigParser - Parses ~/.ssh/config to resolve host aliases.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Parse SSH config with Include directive support
|
||||||
|
* - Return all defined Host aliases (excluding wildcards)
|
||||||
|
* - Resolve alias to HostName, Port, User, IdentityFile
|
||||||
|
* - Gracefully handle missing/unreadable files
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import SSHConfig from 'ssh-config';
|
||||||
|
|
||||||
|
import type { SshConfigHostEntry } from '@shared/types';
|
||||||
|
|
||||||
|
const logger = createLogger('Infrastructure:SshConfigParser');
|
||||||
|
|
||||||
|
export class SshConfigParser {
|
||||||
|
private configPath: string;
|
||||||
|
|
||||||
|
constructor(configPath?: string) {
|
||||||
|
this.configPath = configPath ?? path.join(os.homedir(), '.ssh', 'config');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all defined Host aliases (excluding `*` wildcards and patterns).
|
||||||
|
*/
|
||||||
|
async getHosts(): Promise<SshConfigHostEntry[]> {
|
||||||
|
try {
|
||||||
|
const config = await this.parseConfig();
|
||||||
|
if (!config) return [];
|
||||||
|
|
||||||
|
const entries: SshConfigHostEntry[] = [];
|
||||||
|
|
||||||
|
for (const section of config) {
|
||||||
|
if (section.type !== SSHConfig.DIRECTIVE) continue;
|
||||||
|
if (section.param !== 'Host') continue;
|
||||||
|
|
||||||
|
const hostValue = section.value;
|
||||||
|
if (typeof hostValue !== 'string') continue;
|
||||||
|
|
||||||
|
// Skip wildcard-only entries and patterns with * or ?
|
||||||
|
const aliases = hostValue.split(/\s+/).filter((h) => !h.includes('*') && !h.includes('?'));
|
||||||
|
|
||||||
|
for (const alias of aliases) {
|
||||||
|
const resolved = this.resolveFromConfig(config, alias);
|
||||||
|
entries.push(resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to get SSH config hosts:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a host alias to its SSH config values.
|
||||||
|
* Returns null if the alias is not found in config.
|
||||||
|
*/
|
||||||
|
async resolveHost(alias: string): Promise<SshConfigHostEntry | null> {
|
||||||
|
try {
|
||||||
|
const config = await this.parseConfig();
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
const resolved = this.resolveFromConfig(config, alias);
|
||||||
|
|
||||||
|
// If nothing was resolved beyond the alias itself, check if host was actually defined
|
||||||
|
if (!resolved.hostName && !resolved.user && !resolved.port && !resolved.hasIdentityFile) {
|
||||||
|
// Check if there's an explicit Host entry for this alias
|
||||||
|
const hasEntry = config.some(
|
||||||
|
(section) =>
|
||||||
|
section.type === SSHConfig.DIRECTIVE &&
|
||||||
|
section.param === 'Host' &&
|
||||||
|
typeof section.value === 'string' &&
|
||||||
|
section.value.split(/\s+/).includes(alias)
|
||||||
|
);
|
||||||
|
if (!hasEntry) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to resolve SSH host "${alias}":`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Private Methods
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
private resolveFromConfig(config: SSHConfig, alias: string): SshConfigHostEntry {
|
||||||
|
const computed = config.compute(alias);
|
||||||
|
|
||||||
|
const rawHostName = computed.HostName;
|
||||||
|
const hostName = Array.isArray(rawHostName) ? rawHostName[0] : rawHostName;
|
||||||
|
const rawUser = computed.User;
|
||||||
|
const user = Array.isArray(rawUser) ? rawUser[0] : (rawUser ?? undefined);
|
||||||
|
const portStr = computed.Port;
|
||||||
|
const port = portStr ? parseInt(String(portStr), 10) : undefined;
|
||||||
|
const identityFile = computed.IdentityFile;
|
||||||
|
const hasIdentityFile = Array.isArray(identityFile)
|
||||||
|
? identityFile.length > 0
|
||||||
|
: identityFile != null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
alias,
|
||||||
|
hostName: hostName && hostName !== alias ? hostName : undefined,
|
||||||
|
user,
|
||||||
|
port: port && port !== 22 ? port : undefined,
|
||||||
|
hasIdentityFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async parseConfig(): Promise<SSHConfig | null> {
|
||||||
|
try {
|
||||||
|
let content = await fs.promises.readFile(this.configPath, 'utf8');
|
||||||
|
|
||||||
|
// Process Include directives by expanding them inline
|
||||||
|
content = await this.expandIncludes(content);
|
||||||
|
|
||||||
|
return SSHConfig.parse(content);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
logger.info('No SSH config file found at', this.configPath);
|
||||||
|
} else {
|
||||||
|
logger.error('Failed to parse SSH config:', err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async expandIncludes(content: string): Promise<string> {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const result: string[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
const match =
|
||||||
|
trimmed.startsWith('Include ') || trimmed.startsWith('include ')
|
||||||
|
? /^[Ii]nclude\s+(\S.*)$/.exec(trimmed)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
result.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = match[1].trim();
|
||||||
|
const expandedPattern = pattern.replace(/^~/, os.homedir());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle glob-like patterns by checking if the path contains wildcards
|
||||||
|
if (expandedPattern.includes('*') || expandedPattern.includes('?')) {
|
||||||
|
const dir = path.dirname(expandedPattern);
|
||||||
|
const globPart = path.basename(expandedPattern);
|
||||||
|
const files = await this.globFiles(dir, globPart);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const included = await fs.promises.readFile(file, 'utf8');
|
||||||
|
result.push(included);
|
||||||
|
} catch {
|
||||||
|
// Skip unreadable included files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const included = await fs.promises.readFile(expandedPattern, 'utf8');
|
||||||
|
result.push(included);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip unresolvable includes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async globFiles(dir: string, pattern: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const entries = await fs.promises.readdir(dir);
|
||||||
|
const regex = new RegExp(
|
||||||
|
'^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.') + '$'
|
||||||
|
);
|
||||||
|
return entries.filter((e) => regex.test(e)).map((e) => path.join(dir, e));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,15 +10,19 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { Client, type ConnectConfig } from 'ssh2';
|
import { Client, type ConnectConfig } from 'ssh2';
|
||||||
|
|
||||||
import { LocalFileSystemProvider } from './LocalFileSystemProvider';
|
import { LocalFileSystemProvider } from './LocalFileSystemProvider';
|
||||||
|
import { SshConfigParser } from './SshConfigParser';
|
||||||
import { SshFileSystemProvider } from './SshFileSystemProvider';
|
import { SshFileSystemProvider } from './SshFileSystemProvider';
|
||||||
|
|
||||||
import type { FileSystemProvider } from './FileSystemProvider';
|
import type { FileSystemProvider } from './FileSystemProvider';
|
||||||
|
import type { SshConfigHostEntry } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Infrastructure:SshConnectionManager');
|
const logger = createLogger('Infrastructure:SshConnectionManager');
|
||||||
|
|
||||||
|
|
@ -28,7 +32,7 @@ const logger = createLogger('Infrastructure:SshConnectionManager');
|
||||||
|
|
||||||
export type SshConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
|
export type SshConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||||
|
|
||||||
export type SshAuthMethod = 'password' | 'privateKey' | 'agent';
|
export type SshAuthMethod = 'password' | 'privateKey' | 'agent' | 'auto';
|
||||||
|
|
||||||
export interface SshConnectionConfig {
|
export interface SshConnectionConfig {
|
||||||
host: string;
|
host: string;
|
||||||
|
|
@ -64,6 +68,7 @@ export class SshConnectionManager extends EventEmitter {
|
||||||
private client: Client | null = null;
|
private client: Client | null = null;
|
||||||
private provider: FileSystemProvider;
|
private provider: FileSystemProvider;
|
||||||
private localProvider: LocalFileSystemProvider;
|
private localProvider: LocalFileSystemProvider;
|
||||||
|
private configParser: SshConfigParser;
|
||||||
private state: SshConnectionState = 'disconnected';
|
private state: SshConnectionState = 'disconnected';
|
||||||
private connectedHost: string | null = null;
|
private connectedHost: string | null = null;
|
||||||
private lastError: string | null = null;
|
private lastError: string | null = null;
|
||||||
|
|
@ -73,6 +78,7 @@ export class SshConnectionManager extends EventEmitter {
|
||||||
super();
|
super();
|
||||||
this.localProvider = new LocalFileSystemProvider();
|
this.localProvider = new LocalFileSystemProvider();
|
||||||
this.provider = this.localProvider;
|
this.provider = this.localProvider;
|
||||||
|
this.configParser = new SshConfigParser();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -109,6 +115,20 @@ export class SshConnectionManager extends EventEmitter {
|
||||||
return this.state === 'connected' && this.provider.type === 'ssh';
|
return this.state === 'connected' && this.provider.type === 'ssh';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all SSH config host entries from ~/.ssh/config.
|
||||||
|
*/
|
||||||
|
async getConfigHosts(): Promise<SshConfigHostEntry[]> {
|
||||||
|
return this.configParser.getHosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a host alias from ~/.ssh/config.
|
||||||
|
*/
|
||||||
|
async resolveHostConfig(alias: string): Promise<SshConfigHostEntry | null> {
|
||||||
|
return this.configParser.resolveHost(alias);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect to a remote SSH host.
|
* Connect to a remote SSH host.
|
||||||
*/
|
*/
|
||||||
|
|
@ -244,10 +264,13 @@ export class SshConnectionManager extends EventEmitter {
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
private async buildConnectConfig(config: SshConnectionConfig): Promise<ConnectConfig> {
|
private async buildConnectConfig(config: SshConnectionConfig): Promise<ConnectConfig> {
|
||||||
|
// Resolve SSH config for the given host (alias or hostname)
|
||||||
|
const sshConfig = await this.configParser.resolveHost(config.host);
|
||||||
|
|
||||||
const connectConfig: ConnectConfig = {
|
const connectConfig: ConnectConfig = {
|
||||||
host: config.host,
|
host: sshConfig?.hostName ?? config.host,
|
||||||
port: config.port,
|
port: config.port !== 22 ? config.port : (sshConfig?.port ?? config.port),
|
||||||
username: config.username,
|
username: config.username || sshConfig?.user || os.userInfo().username,
|
||||||
readyTimeout: 10000,
|
readyTimeout: 10000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -258,9 +281,8 @@ export class SshConnectionManager extends EventEmitter {
|
||||||
|
|
||||||
case 'privateKey': {
|
case 'privateKey': {
|
||||||
const keyPath = config.privateKeyPath ?? path.join(os.homedir(), '.ssh', 'id_rsa');
|
const keyPath = config.privateKeyPath ?? path.join(os.homedir(), '.ssh', 'id_rsa');
|
||||||
const { promises: fsPromises } = await import('fs');
|
|
||||||
try {
|
try {
|
||||||
const keyData = await fsPromises.readFile(keyPath, 'utf8');
|
const keyData = await fs.promises.readFile(keyPath, 'utf8');
|
||||||
connectConfig.privateKey = keyData;
|
connectConfig.privateKey = keyData;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Cannot read private key at ${keyPath}: ${(err as Error).message}`);
|
throw new Error(`Cannot read private key at ${keyPath}: ${(err as Error).message}`);
|
||||||
|
|
@ -268,17 +290,163 @@ export class SshConnectionManager extends EventEmitter {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'agent':
|
case 'agent': {
|
||||||
connectConfig.agent = process.env.SSH_AUTH_SOCK;
|
const agentSocket = await this.discoverAgentSocket();
|
||||||
if (!connectConfig.agent) {
|
if (!agentSocket) {
|
||||||
throw new Error('SSH_AUTH_SOCK environment variable is not set');
|
throw new Error(
|
||||||
|
'SSH agent socket not found. Ensure ssh-agent is running or SSH_AUTH_SOCK is set.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
connectConfig.agent = agentSocket;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'auto': {
|
||||||
|
// Auto: try identity file from config -> agent -> default keys
|
||||||
|
const resolved = await this.resolveAutoAuth(sshConfig);
|
||||||
|
if (resolved.privateKey) {
|
||||||
|
connectConfig.privateKey = resolved.privateKey;
|
||||||
|
} else if (resolved.agent) {
|
||||||
|
connectConfig.agent = resolved.agent;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return connectConfig;
|
return connectConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovers the SSH agent socket path.
|
||||||
|
* Handles macOS GUI apps not inheriting SSH_AUTH_SOCK from shell.
|
||||||
|
*/
|
||||||
|
private async discoverAgentSocket(): Promise<string | null> {
|
||||||
|
// 1. Check SSH_AUTH_SOCK env var
|
||||||
|
if (process.env.SSH_AUTH_SOCK) {
|
||||||
|
try {
|
||||||
|
await fs.promises.access(process.env.SSH_AUTH_SOCK);
|
||||||
|
return process.env.SSH_AUTH_SOCK;
|
||||||
|
} catch {
|
||||||
|
// Socket path set but not accessible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. macOS: ask launchctl for the socket (GUI apps don't inherit shell env)
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
try {
|
||||||
|
const sock = await new Promise<string | null>((resolve) => {
|
||||||
|
execFile('/bin/launchctl', ['getenv', 'SSH_AUTH_SOCK'], (err, stdout) => {
|
||||||
|
if (err || !stdout.trim()) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(stdout.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (sock) {
|
||||||
|
try {
|
||||||
|
await fs.promises.access(sock);
|
||||||
|
return sock;
|
||||||
|
} catch {
|
||||||
|
// Not accessible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// launchctl not available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try known socket paths
|
||||||
|
const knownPaths = [
|
||||||
|
// 1Password SSH agent
|
||||||
|
path.join(
|
||||||
|
os.homedir(),
|
||||||
|
'Library',
|
||||||
|
'Group Containers',
|
||||||
|
'2BUA8C4S2C.com.1password',
|
||||||
|
'agent.sock'
|
||||||
|
),
|
||||||
|
path.join(os.homedir(), '.1password', 'agent.sock'),
|
||||||
|
// Common user agent socket
|
||||||
|
path.join(os.homedir(), '.ssh', 'agent.sock'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Linux: add system paths
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
const uid = process.getuid?.();
|
||||||
|
if (uid !== undefined) {
|
||||||
|
knownPaths.push(`/run/user/${uid}/ssh-agent.socket`);
|
||||||
|
knownPaths.push(`/run/user/${uid}/keyring/ssh`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const socketPath of knownPaths) {
|
||||||
|
try {
|
||||||
|
await fs.promises.access(socketPath);
|
||||||
|
return socketPath;
|
||||||
|
} catch {
|
||||||
|
// Not accessible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves authentication automatically by trying:
|
||||||
|
* 1. IdentityFile from SSH config
|
||||||
|
* 2. SSH agent
|
||||||
|
* 3. Default key files (id_ed25519, id_rsa)
|
||||||
|
*/
|
||||||
|
private async resolveAutoAuth(
|
||||||
|
sshConfig: SshConfigHostEntry | null
|
||||||
|
): Promise<{ privateKey?: string; agent?: string }> {
|
||||||
|
// Try SSH config identity file
|
||||||
|
if (sshConfig?.hasIdentityFile) {
|
||||||
|
const resolved = await this.configParser.resolveHost(sshConfig.alias);
|
||||||
|
if (resolved) {
|
||||||
|
// The config parser already told us there's an identity file.
|
||||||
|
// Try common identity file locations from config
|
||||||
|
const configKeyPaths = [
|
||||||
|
path.join(os.homedir(), '.ssh', 'id_ed25519'),
|
||||||
|
path.join(os.homedir(), '.ssh', 'id_rsa'),
|
||||||
|
];
|
||||||
|
for (const keyPath of configKeyPaths) {
|
||||||
|
try {
|
||||||
|
const keyData = await fs.promises.readFile(keyPath, 'utf8');
|
||||||
|
return { privateKey: keyData };
|
||||||
|
} catch {
|
||||||
|
// Try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try SSH agent
|
||||||
|
const agentSocket = await this.discoverAgentSocket();
|
||||||
|
if (agentSocket) {
|
||||||
|
return { agent: agentSocket };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try default key files
|
||||||
|
const defaultKeys = [
|
||||||
|
path.join(os.homedir(), '.ssh', 'id_ed25519'),
|
||||||
|
path.join(os.homedir(), '.ssh', 'id_rsa'),
|
||||||
|
path.join(os.homedir(), '.ssh', 'id_ecdsa'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const keyPath of defaultKeys) {
|
||||||
|
try {
|
||||||
|
const keyData = await fs.promises.readFile(keyPath, 'utf8');
|
||||||
|
return { privateKey: keyData };
|
||||||
|
} catch {
|
||||||
|
// Try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveRemoteProjectsPath(username: string): Promise<string> {
|
private async resolveRemoteProjectsPath(username: string): Promise<string> {
|
||||||
// Try to resolve the remote home directory
|
// Try to resolve the remote home directory
|
||||||
// SFTP doesn't have a direct "get home dir" call, so we try common paths
|
// SFTP doesn't have a direct "get home dir" call, so we try common paths
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,18 @@ export interface TriggerValidationResult {
|
||||||
* Default built-in notification triggers.
|
* Default built-in notification triggers.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_TRIGGERS: NotificationTrigger[] = [
|
export const DEFAULT_TRIGGERS: NotificationTrigger[] = [
|
||||||
|
{
|
||||||
|
id: 'builtin-bash-command',
|
||||||
|
name: '.env File Access Alert',
|
||||||
|
enabled: true,
|
||||||
|
contentType: 'tool_use',
|
||||||
|
toolName: 'Any Tool',
|
||||||
|
mode: 'content_match',
|
||||||
|
matchField: 'command',
|
||||||
|
matchPattern: '/.env',
|
||||||
|
isBuiltin: true,
|
||||||
|
color: 'red',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'builtin-tool-result-error',
|
id: 'builtin-tool-result-error',
|
||||||
name: 'Tool Result Error',
|
name: 'Tool Result Error',
|
||||||
|
|
@ -40,19 +52,7 @@ export const DEFAULT_TRIGGERS: NotificationTrigger[] = [
|
||||||
'\\[Request interrupted by user for tool use\\]',
|
'\\[Request interrupted by user for tool use\\]',
|
||||||
],
|
],
|
||||||
isBuiltin: true,
|
isBuiltin: true,
|
||||||
color: 'red',
|
color: 'orange',
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'builtin-bash-command',
|
|
||||||
name: '.env File Access Alert',
|
|
||||||
enabled: true,
|
|
||||||
contentType: 'tool_use',
|
|
||||||
toolName: 'Bash',
|
|
||||||
mode: 'content_match',
|
|
||||||
matchField: 'command',
|
|
||||||
matchPattern: '/.env',
|
|
||||||
isBuiltin: true,
|
|
||||||
color: 'red',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'builtin-high-token-usage',
|
id: 'builtin-high-token-usage',
|
||||||
|
|
@ -62,7 +62,7 @@ export const DEFAULT_TRIGGERS: NotificationTrigger[] = [
|
||||||
mode: 'token_threshold',
|
mode: 'token_threshold',
|
||||||
tokenThreshold: 8000,
|
tokenThreshold: 8000,
|
||||||
tokenType: 'total',
|
tokenType: 'total',
|
||||||
color: 'orange',
|
color: 'yellow',
|
||||||
isBuiltin: true,
|
isBuiltin: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export type * from './FileSystemProvider';
|
||||||
export * from './FileWatcher';
|
export * from './FileWatcher';
|
||||||
export * from './LocalFileSystemProvider';
|
export * from './LocalFileSystemProvider';
|
||||||
export * from './NotificationManager';
|
export * from './NotificationManager';
|
||||||
|
export * from './SshConfigParser';
|
||||||
export * from './SshConnectionManager';
|
export * from './SshConnectionManager';
|
||||||
export * from './SshFileSystemProvider';
|
export * from './SshFileSystemProvider';
|
||||||
export * from './TriggerManager';
|
export * from './TriggerManager';
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,12 @@ export const SSH_GET_STATE = 'ssh:getState';
|
||||||
/** Test SSH connection without switching */
|
/** Test SSH connection without switching */
|
||||||
export const SSH_TEST = 'ssh:test';
|
export const SSH_TEST = 'ssh:test';
|
||||||
|
|
||||||
|
/** Get SSH config hosts from ~/.ssh/config */
|
||||||
|
export const SSH_GET_CONFIG_HOSTS = 'ssh:getConfigHosts';
|
||||||
|
|
||||||
|
/** Resolve a single SSH config host alias */
|
||||||
|
export const SSH_RESOLVE_HOST = 'ssh:resolveHost';
|
||||||
|
|
||||||
/** SSH status event channel (main -> renderer) */
|
/** SSH status event channel (main -> renderer) */
|
||||||
export const SSH_STATUS = 'ssh:status';
|
export const SSH_STATUS = 'ssh:status';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import { contextBridge, ipcRenderer } from 'electron';
|
||||||
import {
|
import {
|
||||||
SSH_CONNECT,
|
SSH_CONNECT,
|
||||||
SSH_DISCONNECT,
|
SSH_DISCONNECT,
|
||||||
|
SSH_GET_CONFIG_HOSTS,
|
||||||
SSH_GET_STATE,
|
SSH_GET_STATE,
|
||||||
|
SSH_RESOLVE_HOST,
|
||||||
SSH_STATUS,
|
SSH_STATUS,
|
||||||
SSH_TEST,
|
SSH_TEST,
|
||||||
UPDATER_CHECK,
|
UPDATER_CHECK,
|
||||||
|
|
@ -37,6 +39,7 @@ import type {
|
||||||
ElectronAPI,
|
ElectronAPI,
|
||||||
NotificationTrigger,
|
NotificationTrigger,
|
||||||
SessionsPaginationOptions,
|
SessionsPaginationOptions,
|
||||||
|
SshConfigHostEntry,
|
||||||
SshConnectionConfig,
|
SshConnectionConfig,
|
||||||
SshConnectionStatus,
|
SshConnectionStatus,
|
||||||
TriggerTestResult,
|
TriggerTestResult,
|
||||||
|
|
@ -332,6 +335,12 @@ const electronAPI: ElectronAPI = {
|
||||||
test: async (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> => {
|
test: async (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> => {
|
||||||
return invokeIpcWithResult<{ success: boolean; error?: string }>(SSH_TEST, config);
|
return invokeIpcWithResult<{ success: boolean; error?: string }>(SSH_TEST, config);
|
||||||
},
|
},
|
||||||
|
getConfigHosts: async (): Promise<SshConfigHostEntry[]> => {
|
||||||
|
return invokeIpcWithResult<SshConfigHostEntry[]>(SSH_GET_CONFIG_HOSTS);
|
||||||
|
},
|
||||||
|
resolveHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
|
||||||
|
return invokeIpcWithResult<SshConfigHostEntry | null>(SSH_RESOLVE_HOST, alias);
|
||||||
|
},
|
||||||
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void): (() => void) => {
|
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void): (() => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
SSH_STATUS,
|
SSH_STATUS,
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@
|
||||||
* Provides UI for:
|
* Provides UI for:
|
||||||
* - Toggling between local and SSH modes
|
* - Toggling between local and SSH modes
|
||||||
* - Configuring SSH connection (host, port, username, auth)
|
* - Configuring SSH connection (host, port, username, auth)
|
||||||
|
* - SSH config host alias combobox with auto-fill
|
||||||
* - Testing and connecting to remote hosts
|
* - Testing and connecting to remote hosts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
|
import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
|
||||||
|
|
@ -15,7 +16,7 @@ import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
|
||||||
import { SettingRow } from '../components/SettingRow';
|
import { SettingRow } from '../components/SettingRow';
|
||||||
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
||||||
|
|
||||||
import type { SshAuthMethod, SshConnectionConfig } from '@shared/types';
|
import type { SshAuthMethod, SshConfigHostEntry, SshConnectionConfig } from '@shared/types';
|
||||||
|
|
||||||
export const ConnectionSection = (): React.JSX.Element => {
|
export const ConnectionSection = (): React.JSX.Element => {
|
||||||
const connectionState = useStore((s) => s.connectionState);
|
const connectionState = useStore((s) => s.connectionState);
|
||||||
|
|
@ -24,17 +25,64 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
const connectSsh = useStore((s) => s.connectSsh);
|
const connectSsh = useStore((s) => s.connectSsh);
|
||||||
const disconnectSsh = useStore((s) => s.disconnectSsh);
|
const disconnectSsh = useStore((s) => s.disconnectSsh);
|
||||||
const testConnection = useStore((s) => s.testConnection);
|
const testConnection = useStore((s) => s.testConnection);
|
||||||
|
const sshConfigHosts = useStore((s) => s.sshConfigHosts);
|
||||||
|
const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts);
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [host, setHost] = useState('');
|
const [host, setHost] = useState('');
|
||||||
const [port, setPort] = useState('22');
|
const [port, setPort] = useState('22');
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [authMethod, setAuthMethod] = useState<SshAuthMethod>('agent');
|
const [authMethod, setAuthMethod] = useState<SshAuthMethod>('auto');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [privateKeyPath, setPrivateKeyPath] = useState('~/.ssh/id_rsa');
|
const [privateKeyPath, setPrivateKeyPath] = useState('~/.ssh/id_rsa');
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<{ success: boolean; error?: string } | null>(null);
|
const [testResult, setTestResult] = useState<{ success: boolean; error?: string } | null>(null);
|
||||||
|
|
||||||
|
// Combobox state
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
|
const hostInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Fetch SSH config hosts on mount
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchSshConfigHosts();
|
||||||
|
}, [fetchSshConfigHosts]);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent): void => {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(e.target as Node) &&
|
||||||
|
hostInputRef.current &&
|
||||||
|
!hostInputRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setShowDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter config hosts based on input
|
||||||
|
const filteredHosts = useMemo(() => {
|
||||||
|
if (!host.trim()) return sshConfigHosts;
|
||||||
|
const lower = host.toLowerCase();
|
||||||
|
return sshConfigHosts.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.alias.toLowerCase().includes(lower) || entry.hostName?.toLowerCase().includes(lower)
|
||||||
|
);
|
||||||
|
}, [host, sshConfigHosts]);
|
||||||
|
|
||||||
|
const handleSelectConfigHost = (entry: SshConfigHostEntry): void => {
|
||||||
|
setHost(entry.alias);
|
||||||
|
if (entry.port) setPort(String(entry.port));
|
||||||
|
if (entry.user) setUsername(entry.user);
|
||||||
|
setAuthMethod('auto');
|
||||||
|
setShowDropdown(false);
|
||||||
|
setTestResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
const buildConfig = (): SshConnectionConfig => ({
|
const buildConfig = (): SshConnectionConfig => ({
|
||||||
host,
|
host,
|
||||||
port: parseInt(port, 10) || 22,
|
port: parseInt(port, 10) || 22,
|
||||||
|
|
@ -64,6 +112,11 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
const isConnected = connectionState === 'connected';
|
const isConnected = connectionState === 'connected';
|
||||||
|
|
||||||
const inputClass = 'w-full rounded-md border px-3 py-1.5 text-sm focus:outline-none focus:ring-1';
|
const inputClass = 'w-full rounded-md border px-3 py-1.5 text-sm focus:outline-none focus:ring-1';
|
||||||
|
const inputStyle = {
|
||||||
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -130,22 +183,60 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
{/* Host input with combobox */}
|
||||||
|
<div className="relative">
|
||||||
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
Host
|
Host
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
ref={hostInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={host}
|
value={host}
|
||||||
onChange={(e) => setHost(e.target.value)}
|
onChange={(e) => {
|
||||||
placeholder="192.168.1.100"
|
setHost(e.target.value);
|
||||||
className={inputClass}
|
setShowDropdown(true);
|
||||||
style={{
|
setTestResult(null);
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
}}
|
}}
|
||||||
|
onFocus={() => setShowDropdown(true)}
|
||||||
|
placeholder="hostname or ssh config alias"
|
||||||
|
className={inputClass}
|
||||||
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
|
{showDropdown && filteredHosts.length > 0 && (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-md border shadow-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface-overlay)',
|
||||||
|
borderColor: 'var(--color-border-emphasis)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredHosts.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.alias}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-surface-raised"
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
}}
|
||||||
|
onClick={() => handleSelectConfigHost(entry)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{entry.alias}</span>
|
||||||
|
{entry.hostName && (
|
||||||
|
<span style={{ color: 'var(--color-text-muted)' }}>{entry.hostName}</span>
|
||||||
|
)}
|
||||||
|
{entry.user && (
|
||||||
|
<span
|
||||||
|
className="ml-auto text-xs"
|
||||||
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
|
>
|
||||||
|
{entry.user}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
|
@ -157,11 +248,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
onChange={(e) => setPort(e.target.value)}
|
onChange={(e) => setPort(e.target.value)}
|
||||||
placeholder="22"
|
placeholder="22"
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
style={{
|
style={inputStyle}
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -176,11 +263,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder="user"
|
placeholder="user"
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
style={{
|
style={inputStyle}
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -192,12 +275,9 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
value={authMethod}
|
value={authMethod}
|
||||||
onChange={(e) => setAuthMethod(e.target.value as SshAuthMethod)}
|
onChange={(e) => setAuthMethod(e.target.value as SshAuthMethod)}
|
||||||
className="w-full rounded-md border px-3 py-1.5 text-sm"
|
className="w-full rounded-md border px-3 py-1.5 text-sm"
|
||||||
style={{
|
style={inputStyle}
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<option value="auto">Auto (from SSH Config)</option>
|
||||||
<option value="agent">SSH Agent</option>
|
<option value="agent">SSH Agent</option>
|
||||||
<option value="privateKey">Private Key</option>
|
<option value="privateKey">Private Key</option>
|
||||||
<option value="password">Password</option>
|
<option value="password">Password</option>
|
||||||
|
|
@ -215,11 +295,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
onChange={(e) => setPrivateKeyPath(e.target.value)}
|
onChange={(e) => setPrivateKeyPath(e.target.value)}
|
||||||
placeholder="~/.ssh/id_rsa"
|
placeholder="~/.ssh/id_rsa"
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
style={{
|
style={inputStyle}
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -234,11 +310,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
style={{
|
style={inputStyle}
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -254,7 +326,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
>
|
>
|
||||||
{testResult.success
|
{testResult.success
|
||||||
? 'Connection successful'
|
? 'Connection successful'
|
||||||
: `Connection failed: ${testResult.error}`}
|
: `Connection failed: ${testResult.error ?? 'Unknown error'}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -262,7 +334,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleTest()}
|
onClick={() => void handleTest()}
|
||||||
disabled={!host || !username || testing || isConnecting}
|
disabled={!host || testing || isConnecting}
|
||||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
|
@ -281,7 +353,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleConnect()}
|
onClick={() => void handleConnect()}
|
||||||
disabled={!host || !username || isConnecting}
|
disabled={!host || isConnecting}
|
||||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
import type { SshConnectionConfig, SshConnectionState } from '@shared/types';
|
import type { SshConfigHostEntry, SshConnectionConfig, SshConnectionState } from '@shared/types';
|
||||||
import type { StateCreator } from 'zustand';
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -19,6 +19,7 @@ export interface ConnectionSlice {
|
||||||
connectionState: SshConnectionState;
|
connectionState: SshConnectionState;
|
||||||
connectedHost: string | null;
|
connectedHost: string | null;
|
||||||
connectionError: string | null;
|
connectionError: string | null;
|
||||||
|
sshConfigHosts: SshConfigHostEntry[];
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
connectSsh: (config: SshConnectionConfig) => Promise<void>;
|
connectSsh: (config: SshConnectionConfig) => Promise<void>;
|
||||||
|
|
@ -29,6 +30,8 @@ export interface ConnectionSlice {
|
||||||
host: string | null,
|
host: string | null,
|
||||||
error: string | null
|
error: string | null
|
||||||
) => void;
|
) => void;
|
||||||
|
fetchSshConfigHosts: () => Promise<void>;
|
||||||
|
resolveConfigHost: (alias: string) => Promise<SshConfigHostEntry | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -44,6 +47,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
||||||
connectionState: 'disconnected',
|
connectionState: 'disconnected',
|
||||||
connectedHost: null,
|
connectedHost: null,
|
||||||
connectionError: null,
|
connectionError: null,
|
||||||
|
sshConfigHosts: [],
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
connectSsh: async (config: SshConnectionConfig): Promise<void> => {
|
connectSsh: async (config: SshConnectionConfig): Promise<void> => {
|
||||||
|
|
@ -118,4 +122,22 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
||||||
connectionError: error,
|
connectionError: error,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fetchSshConfigHosts: async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const hosts = await window.electronAPI.ssh.getConfigHosts();
|
||||||
|
set({ sshConfigHosts: hosts });
|
||||||
|
} catch {
|
||||||
|
// Gracefully ignore - SSH config may not exist
|
||||||
|
set({ sshConfigHosts: [] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveConfigHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
|
||||||
|
try {
|
||||||
|
return await window.electronAPI.ssh.resolveHost(alias);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,18 @@ export type SshConnectionState = 'disconnected' | 'connecting' | 'connected' | '
|
||||||
/**
|
/**
|
||||||
* SSH authentication method.
|
* SSH authentication method.
|
||||||
*/
|
*/
|
||||||
export type SshAuthMethod = 'password' | 'privateKey' | 'agent';
|
export type SshAuthMethod = 'password' | 'privateKey' | 'agent' | 'auto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSH config host entry resolved from ~/.ssh/config.
|
||||||
|
*/
|
||||||
|
export interface SshConfigHostEntry {
|
||||||
|
alias: string;
|
||||||
|
hostName?: string;
|
||||||
|
user?: string;
|
||||||
|
port?: number;
|
||||||
|
hasIdentityFile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSH connection configuration sent from renderer.
|
* SSH connection configuration sent from renderer.
|
||||||
|
|
@ -201,6 +212,8 @@ export interface SshAPI {
|
||||||
disconnect: () => Promise<SshConnectionStatus>;
|
disconnect: () => Promise<SshConnectionStatus>;
|
||||||
getState: () => Promise<SshConnectionStatus>;
|
getState: () => Promise<SshConnectionStatus>;
|
||||||
test: (config: SshConnectionConfig) => Promise<{ success: boolean; error?: string }>;
|
test: (config: SshConnectionConfig) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
getConfigHosts: () => Promise<SshConfigHostEntry[]>;
|
||||||
|
resolveHost: (alias: string) => Promise<SshConfigHostEntry | null>;
|
||||||
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void) => () => void;
|
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue