Update transfers.service.ts

This commit is contained in:
Baobhan Sith
2025-05-16 19:24:57 +08:00
parent a6dac55045
commit 1dcdea2425
@@ -164,26 +164,94 @@ export class TransfersService {
}
}
private async checkCommandOnSource(client: Client, command: string): Promise<boolean> {
private async checkCommandOnSource(client: Client, command: string): Promise<string | null> {
return new Promise((resolve) => {
client.exec(`command -v ${command}`, (err, stream) => {
const checkCmd = `command -v ${this.escapeShellArg(command)} 2>/dev/null`;
console.error(`[Roo Debug][transfers.service.ts] checkCommandOnSource: Executing: ${checkCmd}`);
client.exec(checkCmd, (err, stream) => {
if (err) {
console.warn(`[TransfersService] Error checking for command '${command}' on source:`, err);
return resolve(false);
console.warn(`[Roo Debug][transfers.service.ts] Error checking for command '${command}' on source:`, err);
return resolve(null);
}
let stdout = '';
stream
.on('data', (data: Buffer) => stdout += data.toString())
.on('close', (code: number) => {
resolve(code === 0 && stdout.trim() !== '');
const foundPath = stdout.trim();
if (code === 0 && foundPath) {
console.error(`[Roo Debug][transfers.service.ts] checkCommandOnSource: Command '${command}' found at '${foundPath}'.`);
resolve(foundPath);
} else {
console.warn(`[Roo Debug][transfers.service.ts] checkCommandOnSource: Command '${command}' not found (exit code: ${code}).`);
resolve(null);
}
})
.stderr.on('data', (data: Buffer) => {
console.warn(`[TransfersService] STDERR checking for command '${command}' on source: ${data.toString()}`);
.stderr.on('data', (data: Buffer) => { // Should be empty due to 2>/dev/null, but good to have
console.warn(`[Roo Debug][transfers.service.ts] checkCommandOnSource: STDERR for '${command}': ${data.toString()}`);
});
});
});
}
private async checkCommandOnTargetServer(targetConnection: ConnectionWithTags, targetCredentials: DecryptedConnectionCredentials, command: string): Promise<string | null> {
const targetClient = new Client();
const connectConfig = this.buildSshConnectConfig(targetConnection, targetCredentials);
let foundCommandPath: string | null = null;
console.error(`[Roo Debug][transfers.service.ts] checkCommandOnTargetServer: Attempting to connect to target ${targetConnection.host} to check for command '${command}'.`);
try {
await new Promise<void>((resolve, reject) => {
targetClient
.on('ready', () => {
console.info(`[TransfersService] SSH connection established to target server ${targetConnection.host} for command check.`);
resolve();
})
.on('error', (err) => {
console.error(`[TransfersService] SSH connection error to target server ${targetConnection.host} for command check:`, err);
reject(err);
})
.on('close', () => {
console.info(`[TransfersService] SSH connection closed to target server ${targetConnection.host} after command check.`);
})
.connect(connectConfig);
});
foundCommandPath = await new Promise((resolve) => {
const checkCmd = `command -v ${this.escapeShellArg(command)} 2>/dev/null`;
console.error(`[Roo Debug][transfers.service.ts] checkCommandOnTargetServer: Executing on target: ${checkCmd}`);
targetClient.exec(checkCmd, (err, stream) => {
if (err) {
console.warn(`[Roo Debug][transfers.service.ts] Error checking for command '${command}' on target ${targetConnection.host}:`, err);
return resolve(null);
}
let stdout = '';
stream
.on('data', (data: Buffer) => stdout += data.toString())
.on('close', (code: number) => {
const pathOutput = stdout.trim();
if (code === 0 && pathOutput) {
console.error(`[Roo Debug][transfers.service.ts] checkCommandOnTargetServer: Command '${command}' found at '${pathOutput}' on target ${targetConnection.host}.`);
resolve(pathOutput);
} else {
console.warn(`[Roo Debug][transfers.service.ts] checkCommandOnTargetServer: Command '${command}' not found on target ${targetConnection.host} (exit code: ${code}).`);
resolve(null);
}
})
.stderr.on('data', (data: Buffer) => {
console.warn(`[Roo Debug][transfers.service.ts] checkCommandOnTargetServer: STDERR for '${command}' on target ${targetConnection.host}: ${data.toString()}`);
});
});
});
} catch (error) {
console.error(`[Roo Debug][transfers.service.ts] checkCommandOnTargetServer: Failed to check command '${command}' on target ${targetConnection.host}:`, error);
foundCommandPath = null; // Ensure it's null on error
} finally {
targetClient.end();
}
return foundCommandPath;
}
private async uploadKeyToSourceViaSftp(client: Client, privateKeyContent: string, remotePath: string): Promise<void> {
console.error(`[Roo Debug][transfers.service.ts] ENTERING uploadKeyToSourceViaSftp for remotePath: ${remotePath}`);
const SFTP_UPLOAD_TIMEOUT_MS = 30000; // 30 seconds timeout for SFTP key upload
@@ -271,7 +339,8 @@ export class TransfersService {
isDir: boolean,
targetConnection: ConnectionWithTags, // Target B connection details
targetPathOnB: string, // Base remote target path on B
transferCmd: 'scp' | 'rsync',
executableCommand: string, // Full path to rsync or scp
commandType: 'rsync' | 'scp', // To distinguish logic
options: { // Options derived from checking source A and target B auth
sshPassCommand?: string; // e.g., "sshpass -p 'password'"
sshIdentityFileOption?: string; // e.g., "-i /tmp/key_B_XYZ"
@@ -280,38 +349,50 @@ export class TransfersService {
}
): string {
const remoteBase = targetPathOnB.endsWith('/') ? targetPathOnB : `${targetPathOnB}/`;
const remoteFullDest = `${options.targetUserAndHost}:${this.escapeShellArg(remoteBase)}`; // SCP/Rsync will append filename if source is file
const remoteFullDest = `${options.targetUserAndHost}:${this.escapeShellArg(remoteBase)}`;
let commandParts: string[] = [];
if (options.sshPassCommand) {
commandParts.push(options.sshPassCommand);
}
if (transferCmd === 'rsync') {
commandParts.push('rsync -avz --progress');
// Use the full path here (should be safe, no special chars from command -v)
// Arguments will still be quoted later.
commandParts.push(executableCommand);
if (commandType === 'rsync') {
commandParts.push('-avz --progress'); // rsync specific options
// For rsync, SSH options go into the -e argument
let sshArgsForRsync = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
if (options.sshPortOption && options.sshPortOption.startsWith('-p')) { // for rsync -e "ssh -p XXX"
if (options.sshPortOption && options.sshPortOption.startsWith('-p')) { // rsync uses -p for port in its -e "ssh -p XXX"
sshArgsForRsync += ` ${options.sshPortOption}`;
}
if (options.sshIdentityFileOption) {
if (options.sshIdentityFileOption) { // -i for identity file is an ssh option
sshArgsForRsync += ` ${options.sshIdentityFileOption}`;
}
commandParts.push(`-e "${sshArgsForRsync.trim()}"`);
let rsyncSourcePath = this.escapeShellArg(sourceItemPathOnA);
if (isDir && !rsyncSourcePath.endsWith('/\'')) { // if escaped and ends with /'
rsyncSourcePath = rsyncSourcePath.slice(0, -1) + '/\''; // Add trailing slash for rsync dir content copy
if (isDir && !rsyncSourcePath.endsWith('/\'')) {
rsyncSourcePath = rsyncSourcePath.slice(0, -1) + '/\'';
}
commandParts.push(rsyncSourcePath);
commandParts.push(remoteFullDest);
} else { // scp
commandParts.push('scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null');
// For scp, SSH options are typically passed directly if scp is a wrapper around ssh, or via scp's own options that map to ssh options.
// Common scp implementations accept -P for port and -i for identity file directly.
// StrictHostKeyChecking and UserKnownHostsFile are ssh options.
// We build the ssh part for scp separately if needed, or rely on scp passing -o options.
// Let's assume scp will pass these -o options to its underlying ssh call.
// If not, a more complex construction of scp's ssh command via -S might be needed.
commandParts.push('-o StrictHostKeyChecking=no'); // For scp, pass as direct option
commandParts.push('-o UserKnownHostsFile=/dev/null'); // For scp, pass as direct option
if (isDir) commandParts.push('-r');
if (options.sshPortOption && options.sshPortOption.startsWith('-P')) { // for scp -P XXX
if (options.sshPortOption && options.sshPortOption.startsWith('-P')) { // scp uses -P for port
commandParts.push(options.sshPortOption);
}
if (options.sshIdentityFileOption) {
if (options.sshIdentityFileOption) { // scp uses -i for identity file
commandParts.push(options.sshIdentityFileOption);
}
commandParts.push(this.escapeShellArg(sourceItemPathOnA));
@@ -319,7 +400,7 @@ export class TransfersService {
}
return commandParts.join(' ');
}
private async executeRemoteTransferOnSource(
taskId: string,
subTaskId: string,
@@ -333,30 +414,97 @@ export class TransfersService {
): Promise<void> {
console.error(`[Roo Debug][transfers.service.ts] ENTERING executeRemoteTransferOnSource for sub-task ${subTaskId}, item: ${sourceItem.name}`);
this.updateSubTaskStatus(taskId, subTaskId, 'transferring', 0, `Initializing remote transfer for ${sourceItem.name}`);
let tempTargetKeyPathOnSource: string | undefined; // Path of target's private key if temporarily on source A
let tempTargetKeyPathOnSource: string | undefined;
try {
console.error(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: Starting try block in executeRemoteTransferOnSource.`);
const sshpassAvailableOnSource = await this.checkCommandOnSource(sourceSshClient, 'sshpass');
const rsyncAvailableOnSource = await this.checkCommandOnSource(sourceSshClient, 'rsync');
const sshpassPath = await this.checkCommandOnSource(sourceSshClient, 'sshpass');
const rsyncPathOnSource = await this.checkCommandOnSource(sourceSshClient, 'rsync'); // Renamed for clarity
const scpPathOnSource = await this.checkCommandOnSource(sourceSshClient, 'scp'); // Renamed for clarity
let determinedTransferCmd: 'scp' | 'rsync' = 'scp'; // Default to scp
if (transferMethodPreference === 'rsync' && rsyncAvailableOnSource) {
determinedTransferCmd = 'rsync';
} else if (transferMethodPreference === 'rsync' && !rsyncAvailableOnSource) {
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, `Rsync preferred but not available on source server. Sub-task for ${sourceItem.name} failed.`);
throw new Error('Rsync preferred but not available on source server.');
} else if (transferMethodPreference === 'auto') {
determinedTransferCmd = rsyncAvailableOnSource ? 'rsync' : 'scp';
console.error(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: Source checks -> sshpass: ${sshpassPath}, rsync: ${rsyncPathOnSource}, scp: ${scpPathOnSource}`);
let executableCommandPath: string | null = null;
let commandTypeForLogic: 'rsync' | 'scp' | undefined = undefined; // Initialize as undefined
let rsyncPathOnTarget: string | null = null;
if (transferMethodPreference === 'auto') {
if (rsyncPathOnSource) {
// Source has rsync, check target
console.error(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: 'auto' mode, rsync found on source. Checking target...`);
rsyncPathOnTarget = await this.checkCommandOnTargetServer(targetConnection, targetCredentials, 'rsync');
if (rsyncPathOnTarget) {
console.error(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: Target check (for auto rsync) -> rsync found at '${rsyncPathOnTarget}'. Selecting rsync.`);
executableCommandPath = rsyncPathOnSource; // Use source path for exec
commandTypeForLogic = 'rsync';
} else {
console.warn(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: Rsync found on source, but NOT on target. Rsync cannot be used for 'auto' mode.`);
}
}
// If rsync not chosen (either source or target missing), try SCP for 'auto'
if (!commandTypeForLogic) {
if (scpPathOnSource) {
executableCommandPath = scpPathOnSource;
commandTypeForLogic = 'scp';
console.error(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: 'auto' mode falling back to SCP (Source SCP path: ${scpPathOnSource}).`);
} else {
const msg = `Neither Rsync (source/target) nor SCP (source) are available for ${sourceItem.name} (auto mode).`;
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, msg);
throw new Error(msg);
}
}
} else if (transferMethodPreference === 'rsync') {
if (rsyncPathOnSource) {
console.error(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: 'rsync' preference, rsync found on source. Checking target...`);
rsyncPathOnTarget = await this.checkCommandOnTargetServer(targetConnection, targetCredentials, 'rsync');
if (rsyncPathOnTarget) {
console.error(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: Target check (for preferred rsync) -> rsync found at '${rsyncPathOnTarget}'. Selecting rsync.`);
executableCommandPath = rsyncPathOnSource;
commandTypeForLogic = 'rsync';
} else {
const msg = `Rsync preferred, found on source, but rsync is NOT available on target server for ${sourceItem.name}.`;
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, msg);
throw new Error(msg);
}
} else {
const msg = `Rsync preferred but not available on source server for ${sourceItem.name}.`;
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, msg);
throw new Error(msg);
}
} else if (transferMethodPreference === 'scp') {
if (scpPathOnSource) {
executableCommandPath = scpPathOnSource;
commandTypeForLogic = 'scp';
console.error(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: 'scp' preference. Selecting SCP (Source SCP path: ${scpPathOnSource}).`);
} else {
const msg = `SCP preferred but not available on source server for ${sourceItem.name}.`;
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, msg);
throw new Error(msg);
}
} else {
// This case should ideally not be reached if transferMethodPreference is correctly typed
const msg = `Invalid transfer method preference: ${transferMethodPreference}`;
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, msg);
throw new Error(msg);
}
// Safeguard: ensure a command was actually selected
if (!executableCommandPath || !commandTypeForLogic) {
const msg = `Internal error: Could not determine a valid transfer command for ${sourceItem.name}. This should not happen.`;
console.error(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: ${msg} rsyncSrc=${rsyncPathOnSource}, scpSrc=${scpPathOnSource}, rsyncTgt=${rsyncPathOnTarget}, pref=${transferMethodPreference}`);
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, msg);
throw new Error(msg);
}
this.updateSubTaskStatus(taskId, subTaskId, 'transferring', 5, `Using ${determinedTransferCmd}. Source SSHPass: ${sshpassAvailableOnSource}, Rsync: ${rsyncAvailableOnSource}`);
const subTaskToUpdate = this.transferTasks.get(taskId)?.subTasks.find(st => st.subTaskId === subTaskId);
if (subTaskToUpdate) subTaskToUpdate.transferMethodUsed = determinedTransferCmd;
this.updateSubTaskStatus(taskId, subTaskId, 'transferring', 5, `Using ${commandTypeForLogic} (Path: ${executableCommandPath}). Source SSHPass: ${!!sshpassPath}, Rsync Src: ${!!rsyncPathOnSource}, Rsync Tgt: ${!!rsyncPathOnTarget}, SCP Src: ${!!scpPathOnSource}`);
const subTaskToUpdate = this.transferTasks.get(taskId)?.subTasks.find(st => st.subTaskId === subTaskId);
if (subTaskToUpdate) subTaskToUpdate.transferMethodUsed = commandTypeForLogic;
const cmdOptions: any = {
targetUserAndHost: `${targetConnection.username}@${targetConnection.host}`,
sshPortOption: targetConnection.port ? (determinedTransferCmd === 'scp' ? `-P ${targetConnection.port}`: `-p ${targetConnection.port}`) : undefined,
// Port for rsync is handled in buildTransferCommandString via -e "ssh -p <port>"
// Port for scp is -P <port>
sshPortOption: targetConnection.port ? (commandTypeForLogic === 'scp' ? `-P ${targetConnection.port}` : (commandTypeForLogic === 'rsync' ? `-p ${targetConnection.port}` : undefined)) : undefined,
};
if (targetConnection.auth_method === 'key' && targetCredentials.decryptedPrivateKey) {
@@ -369,8 +517,8 @@ export class TransfersService {
cmdOptions.sshIdentityFileOption = `-i ${this.escapeShellArg(tempTargetKeyPathOnSource)}`;
if (targetCredentials.decryptedPassphrase) {
if (sshpassAvailableOnSource) {
cmdOptions.sshPassCommand = `sshpass -p ${this.escapeShellArg(targetCredentials.decryptedPassphrase)}`;
if (sshpassPath) { // Check if sshpassPath is not null
cmdOptions.sshPassCommand = `${this.escapeShellArg(sshpassPath)} -p ${this.escapeShellArg(targetCredentials.decryptedPassphrase)}`;
} else {
const msg = `Target key has passphrase, but sshpass is not available on source server for ${sourceItem.name}.`;
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, msg);
@@ -378,8 +526,8 @@ export class TransfersService {
}
}
} else if (targetConnection.auth_method === 'password' && targetCredentials.decryptedPassword) {
if (sshpassAvailableOnSource) {
cmdOptions.sshPassCommand = `sshpass -p ${this.escapeShellArg(targetCredentials.decryptedPassword)}`;
if (sshpassPath) { // Check if sshpassPath is not null
cmdOptions.sshPassCommand = `${this.escapeShellArg(sshpassPath)} -p ${this.escapeShellArg(targetCredentials.decryptedPassword)}`;
} else {
const msg = `Target uses password auth, but sshpass is not available on source server for ${sourceItem.name}.`;
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, msg);
@@ -397,24 +545,25 @@ export class TransfersService {
sourceItem.type === 'directory',
targetConnection,
remoteTargetPathOnTarget,
determinedTransferCmd,
executableCommandPath!, // Assert not null as we'd have thrown earlier
commandTypeForLogic,
cmdOptions
);
console.info(`[TransfersService] Executing on source for sub-task ${subTaskId} (item: ${sourceItem.name}): ${commandToExecute}`);
console.info(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: Prepared command: ${commandToExecute}`);
console.info(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: Command options: ${JSON.stringify(cmdOptions)}`);
this.updateSubTaskStatus(taskId, subTaskId, 'transferring', 10, `Executing: ${determinedTransferCmd} from source to ${targetConnection.name}`);
this.updateSubTaskStatus(taskId, subTaskId, 'transferring', 10, `Executing: ${commandTypeForLogic} from source to ${targetConnection.name}`);
const COMMAND_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes timeout for command execution
await new Promise<void>((resolveCmd, rejectCmd) => {
let commandTimedOut = false;
let stdoutCombined = ''; // Moved here to be accessible by timeout
let stderrCombined = ''; // Moved here to be accessible by timeout
let stdoutCombined = '';
let stderrCombined = '';
const timeoutHandle = setTimeout(() => {
commandTimedOut = true;
const timeoutMsg = `${determinedTransferCmd} command for ${sourceItem.name} timed out after ${COMMAND_TIMEOUT_MS / 1000}s.`;
const timeoutMsg = `${commandTypeForLogic} command for ${sourceItem.name} timed out after ${COMMAND_TIMEOUT_MS / 1000}s.`;
console.warn(`[Roo Debug][transfers.service.ts] TIMEOUT ${timeoutMsg} (Sub-task: ${subTaskId})`);
console.warn(`[Roo Debug][transfers.service.ts] TIMEOUT Sub-task ${subTaskId}: STDOUT at timeout: ${stdoutCombined}`);
console.warn(`[Roo Debug][transfers.service.ts] TIMEOUT Sub-task ${subTaskId}: STDERR at timeout: ${stderrCombined}`);
@@ -449,12 +598,12 @@ export class TransfersService {
stdoutCombined += output;
// More verbose logging for stdout
console.debug(`[Roo Debug][transfers.service.ts] RAW STDOUT Sub-task ${subTaskId} (item ${sourceItem.name}): <<<${output}>>>`);
if (determinedTransferCmd === 'rsync') {
if (commandTypeForLogic === 'rsync') {
const progressMatch = output.match(/(\d+)%/);
if (progressMatch && progressMatch[1]) {
this.updateSubTaskStatus(taskId, subTaskId, 'transferring', parseInt(progressMatch[1], 10));
}
} else {
} else { // scp
this.updateSubTaskStatus(taskId, subTaskId, 'transferring', 50, 'SCP in progress...');
}
});
@@ -478,23 +627,23 @@ export class TransfersService {
console.info(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: Final STDERR: ${stderrCombined}`);
if (code === 0) {
this.updateSubTaskStatus(taskId, subTaskId, 'completed', 100, `${determinedTransferCmd} successful for ${sourceItem.name} to ${targetConnection.name}.`);
this.updateSubTaskStatus(taskId, subTaskId, 'completed', 100, `${commandTypeForLogic} successful for ${sourceItem.name} to ${targetConnection.name}.`);
resolveCmd();
} else {
const errorMsg = `${determinedTransferCmd} failed for ${sourceItem.name} to ${targetConnection.name}. Exit code: ${code}, signal: ${signal}. Stderr: ${stderrCombined.trim()}`;
const errorMsg = `${commandTypeForLogic} failed for ${sourceItem.name} to ${targetConnection.name}. Exit code: ${code}, signal: ${signal}. Stderr: ${stderrCombined.trim()}`;
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, errorMsg);
rejectCmd(new Error(errorMsg));
}
});
stream.on('error', (streamErr: Error) => { // Should not happen if exec cb err is null
stream.on('error', (streamErr: Error) => {
clearTimeout(timeoutHandle);
if (commandTimedOut) {
console.info(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: Stream error AFTER timeout.`);
return;
}
console.error(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: Stream error event:`, streamErr);
const errorMsg = `Stream error during ${determinedTransferCmd} for ${sourceItem.name}: ${streamErr.message}`;
const errorMsg = `Stream error during ${commandTypeForLogic} for ${sourceItem.name}: ${streamErr.message}`;
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, errorMsg);
rejectCmd(streamErr);
});