@wyxos/zephyr 0.2.12 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,16 +27,30 @@ function logWarning(message) {
27
27
 
28
28
  function runCommand(command, args, { cwd = process.cwd(), capture = false, useShell = false } = {}) {
29
29
  return new Promise((resolve, reject) => {
30
+ const needsShell = useShell || (IS_WINDOWS && (command === 'php' || command === 'composer'))
31
+
30
32
  const spawnOptions = {
31
33
  cwd,
32
34
  stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'
33
35
  }
34
36
 
35
- if (useShell || (IS_WINDOWS && (command === 'php' || command === 'composer'))) {
37
+ let child
38
+ if (needsShell) {
39
+ // When using shell, construct the command string to avoid deprecation warning
40
+ // Properly escape arguments for Windows cmd.exe
41
+ const escapedArgs = args.map(arg => {
42
+ // If arg contains spaces or special chars, wrap in quotes and escape internal quotes
43
+ if (arg.includes(' ') || arg.includes('"') || arg.includes('&') || arg.includes('|')) {
44
+ return `"${arg.replace(/"/g, '\\"')}"`
45
+ }
46
+ return arg
47
+ })
48
+ const commandString = `${command} ${escapedArgs.join(' ')}`
36
49
  spawnOptions.shell = true
50
+ child = spawn(commandString, [], spawnOptions)
51
+ } else {
52
+ child = spawn(command, args, spawnOptions)
37
53
  }
38
-
39
- const child = spawn(command, args, spawnOptions)
40
54
  let stdout = ''
41
55
  let stderr = ''
42
56
 
package/src/ssh-utils.mjs CHANGED
@@ -1,278 +1,278 @@
1
- import fs from 'node:fs/promises'
2
- import os from 'node:os'
3
- import path from 'node:path'
4
- import { NodeSSH } from 'node-ssh'
5
- import chalk from 'chalk'
6
-
7
- // Import utility functions - these need to be passed in or redefined to avoid circular dependency
8
- // For now, we'll redefine the simple ones and accept others as parameters
9
- const logProcessing = (message = '') => console.log(chalk.yellow(message))
10
- const logSuccess = (message = '') => console.log(chalk.green(message))
11
- const logError = (message = '') => console.error(chalk.red(message))
12
- const logWarning = (message = '') => console.warn(chalk.yellow(message))
13
-
14
- function expandHomePath(targetPath) {
15
- if (!targetPath) {
16
- return targetPath
17
- }
18
- if (targetPath.startsWith('~')) {
19
- return path.join(os.homedir(), targetPath.slice(1))
20
- }
21
- return targetPath
22
- }
23
-
24
- async function resolveSshKeyPath(targetPath) {
25
- const expanded = expandHomePath(targetPath)
26
- try {
27
- await fs.access(expanded)
28
- } catch (error) {
29
- throw new Error(`SSH key not accessible at ${expanded}`)
30
- }
31
- return expanded
32
- }
33
-
34
- function resolveRemotePath(projectPath, remoteHome) {
35
- if (!projectPath) {
36
- return projectPath
37
- }
38
- const sanitizedHome = remoteHome.replace(/\/+$/, '')
39
- if (projectPath === '~') {
40
- return sanitizedHome
41
- }
42
- if (projectPath.startsWith('~/')) {
43
- const remainder = projectPath.slice(2)
44
- return remainder ? `${sanitizedHome}/${remainder}` : sanitizedHome
45
- }
46
- if (projectPath.startsWith('/')) {
47
- return projectPath
48
- }
49
- return `${sanitizedHome}/${projectPath}`
50
- }
51
-
52
- const createSshClient = () => {
53
- if (typeof globalThis !== 'undefined' && globalThis.__zephyrSSHFactory) {
54
- return globalThis.__zephyrSSHFactory()
55
- }
56
- return new NodeSSH()
57
- }
58
-
59
- // writeToLogFile will be passed as an optional parameter to avoid circular dependency
60
-
61
- /**
62
- * Connect to server via SSH
63
- * @param {Object} config - Server configuration with sshUser, sshKey, serverIp, projectPath
64
- * @param {string} rootDir - Local root directory for logging
65
- * @returns {Promise<{ssh: NodeSSH, remoteCwd: string, remoteHome: string}>}
66
- */
67
- export async function connectToServer(config, rootDir) {
68
- const ssh = createSshClient()
69
- const sshUser = config.sshUser || os.userInfo().username
70
- const privateKeyPath = await resolveSshKeyPath(config.sshKey)
71
- const privateKey = await fs.readFile(privateKeyPath, 'utf8')
72
-
73
- logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
74
-
75
- await ssh.connect({
76
- host: config.serverIp,
77
- username: sshUser,
78
- privateKey
79
- })
80
-
81
- const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
82
- const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
83
- const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
84
-
85
- logSuccess(`Connected to ${config.serverIp}. Working directory: ${remoteCwd}`)
86
-
87
- return { ssh, remoteCwd, remoteHome }
88
- }
89
-
90
- /**
91
- * Execute a remote command with logging
92
- * @param {NodeSSH} ssh - SSH client instance
93
- * @param {string} label - Human-readable label for the command
94
- * @param {string} command - Command to execute
95
- * @param {Object} options - Options: { cwd, allowFailure, printStdout, bootstrapEnv, rootDir, writeToLogFile, env }
96
- * @returns {Promise<Object>} Command result
97
- */
98
- export async function executeRemoteCommand(ssh, label, command, options = {}) {
99
- const { cwd, allowFailure = false, printStdout = true, bootstrapEnv = true, rootDir = null, writeToLogFile = null, env = {} } = options
100
-
101
- logProcessing(`\n→ ${label}`)
102
-
103
- // Robust environment bootstrap for non-interactive shells
104
- const profileBootstrap = [
105
- 'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile"; fi',
106
- 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile"; fi',
107
- 'if [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi',
108
- 'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile"; fi',
109
- 'if [ -f "$HOME/.zshrc" ]; then . "$HOME/.zshrc"; fi',
110
- 'if [ -s "$HOME/.nvm/nvm.sh" ]; then . "$HOME/.nvm/nvm.sh"; fi',
111
- 'if [ -s "$HOME/.config/nvm/nvm.sh" ]; then . "$HOME/.config/nvm/nvm.sh"; fi',
112
- 'if [ -s "/usr/local/opt/nvm/nvm.sh" ]; then . "/usr/local/opt/nvm/nvm.sh"; fi',
113
- 'if command -v npm >/dev/null 2>&1; then :',
114
- 'elif [ -d "$HOME/.nvm/versions/node" ]; then NODE_VERSION=$(ls -1 "$HOME/.nvm/versions/node" | tail -1) && export PATH="$HOME/.nvm/versions/node/$NODE_VERSION/bin:$PATH"',
115
- 'elif [ -d "/usr/local/lib/node_modules/npm/bin" ]; then export PATH="/usr/local/lib/node_modules/npm/bin:$PATH"',
116
- 'elif [ -d "/opt/homebrew/bin" ] && [ -f "/opt/homebrew/bin/npm" ]; then export PATH="/opt/homebrew/bin:$PATH"',
117
- 'elif [ -d "/usr/local/bin" ] && [ -f "/usr/local/bin/npm" ]; then export PATH="/usr/local/bin:$PATH"',
118
- 'elif [ -d "$HOME/.local/bin" ] && [ -f "$HOME/.local/bin/npm" ]; then export PATH="$HOME/.local/bin:$PATH"',
119
- 'fi'
120
- ].join('; ')
121
-
122
- const escapeForDoubleQuotes = (value) => value.replace(/(["\\$`])/g, '\\$1')
123
- const escapeForSingleQuotes = (value) => value.replace(/'/g, "'\\''")
124
-
125
- // Build environment variable exports
126
- let envExports = ''
127
- if (Object.keys(env).length > 0) {
128
- const envPairs = Object.entries(env).map(([key, value]) => {
129
- const escapedValue = escapeForSingleQuotes(String(value))
130
- return `${key}='${escapedValue}'`
131
- })
132
- envExports = envPairs.join(' ') + ' '
133
- }
134
-
135
- let wrappedCommand = command
136
- let execOptions = { cwd }
137
-
138
- if (bootstrapEnv && cwd) {
139
- const cwdForShell = escapeForDoubleQuotes(cwd)
140
- wrappedCommand = `${profileBootstrap}; cd "${cwdForShell}" && ${envExports}${command}`
141
- execOptions = {}
142
- } else if (Object.keys(env).length > 0) {
143
- wrappedCommand = `${envExports}${command}`
144
- }
145
-
146
- const result = await ssh.execCommand(wrappedCommand, execOptions)
147
-
148
- // Log to file if writeToLogFile function provided
149
- if (writeToLogFile && rootDir) {
150
- if (result.stdout && result.stdout.trim()) {
151
- await writeToLogFile(rootDir, `[${label}] STDOUT:\n${result.stdout.trim()}`)
152
- }
153
- if (result.stderr && result.stderr.trim()) {
154
- await writeToLogFile(rootDir, `[${label}] STDERR:\n${result.stderr.trim()}`)
155
- }
156
- }
157
-
158
- // Show errors in terminal
159
- if (result.code !== 0) {
160
- if (result.stdout && result.stdout.trim()) {
161
- logError(`\n[${label}] Output:\n${result.stdout.trim()}`)
162
- }
163
- if (result.stderr && result.stderr.trim()) {
164
- logError(`\n[${label}] Error:\n${result.stderr.trim()}`)
165
- }
166
- }
167
-
168
- if (result.code !== 0 && !allowFailure) {
169
- const stderr = result.stderr?.trim() ?? ''
170
- if (/command not found/.test(stderr) || /is not recognized/.test(stderr)) {
171
- throw new Error(
172
- `Command failed: ${command}. Ensure the remote environment loads required tools for non-interactive shells (e.g. export PATH in profile scripts).`
173
- )
174
- }
175
- throw new Error(`Command failed: ${command}`)
176
- }
177
-
178
- // Show success confirmation
179
- if (result.code === 0) {
180
- logSuccess(`✓ ${command}`)
181
- }
182
-
183
- return result
184
- }
185
-
186
- /**
187
- * Read file content from remote server
188
- * @param {NodeSSH} ssh - SSH client instance
189
- * @param {string} filePath - Path to file on remote server
190
- * @param {string} remoteCwd - Remote working directory
191
- * @returns {Promise<string>} File content
192
- */
193
- export async function readRemoteFile(ssh, filePath, remoteCwd) {
194
- const escapedPath = filePath.replace(/'/g, "'\\''")
195
- const command = `cat '${escapedPath}'`
196
-
197
- const result = await ssh.execCommand(command, { cwd: remoteCwd })
198
-
199
- if (result.code !== 0) {
200
- throw new Error(`Failed to read remote file ${filePath}: ${result.stderr}`)
201
- }
202
-
203
- return result.stdout
204
- }
205
-
206
- /**
207
- * Download file from remote server via SFTP with progress
208
- *
209
- * Note: Currently uses single-stream download (most reliable).
210
- * Multi-streaming is technically possible with ssh2-sftp-client's fastGet,
211
- * but it's unreliable on many servers and can cause data corruption.
212
- * Single-stream ensures data integrity at the cost of potentially slower speeds.
213
- *
214
- * @param {NodeSSH} ssh - SSH client instance
215
- * @param {string} remotePath - Path to file on remote server
216
- * @param {string} localPath - Local path to save file
217
- * @param {string} remoteCwd - Remote working directory (for relative paths)
218
- * @returns {Promise<void>}
219
- */
220
- export async function downloadRemoteFile(ssh, remotePath, localPath, remoteCwd) {
221
- // Resolve absolute path if relative
222
- const absoluteRemotePath = remotePath.startsWith('/')
223
- ? remotePath
224
- : `${remoteCwd}/${remotePath}`
225
-
226
- logProcessing(`Downloading ${absoluteRemotePath} to ${localPath}...`)
227
-
228
- let transferred = 0
229
- const startTime = Date.now()
230
-
231
- // Single-stream download (most reliable for data integrity)
232
- await ssh.getFile(localPath, absoluteRemotePath, null, {
233
- step: (totalTransferred, chunk, total) => {
234
- transferred = totalTransferred
235
- const percent = total > 0 ? Math.round((transferred / total) * 100) : 0
236
- const elapsed = (Date.now() - startTime) / 1000
237
- const speed = elapsed > 0 ? (transferred / elapsed / 1024 / 1024).toFixed(2) : 0
238
- const sizeMB = (transferred / 1024 / 1024).toFixed(2)
239
- const totalMB = total > 0 ? (total / 1024 / 1024).toFixed(2) : '?'
240
-
241
- // Update progress on same line
242
- process.stdout.write(`\r Progress: ${percent}% (${sizeMB}MB / ${totalMB}MB) - ${speed} MB/s`)
243
- }
244
- })
245
-
246
- // Clear progress line and show completion
247
- process.stdout.write('\r' + ' '.repeat(80) + '\r')
248
- logSuccess(`Downloaded ${absoluteRemotePath} to ${localPath}`)
249
- }
250
-
251
- /**
252
- * Delete file from remote server
253
- * @param {NodeSSH} ssh - SSH client instance
254
- * @param {string} remotePath - Path to file on remote server
255
- * @param {string} remoteCwd - Remote working directory (for relative paths)
256
- * @returns {Promise<void>}
257
- */
258
- export async function deleteRemoteFile(ssh, remotePath, remoteCwd) {
259
- // Resolve absolute path if relative
260
- const absoluteRemotePath = remotePath.startsWith('/')
261
- ? remotePath
262
- : `${remoteCwd}/${remotePath}`
263
-
264
- const escapedPath = absoluteRemotePath.replace(/'/g, "'\\''")
265
- const command = `rm -f '${escapedPath}'`
266
-
267
- logProcessing(`Deleting remote file: ${absoluteRemotePath}...`)
268
-
269
- const result = await ssh.execCommand(command, { cwd: remoteCwd })
270
-
271
- if (result.code !== 0 && result.code !== 1) {
272
- // Exit code 1 is OK for rm -f (file doesn't exist)
273
- logWarning(`Failed to delete remote file ${absoluteRemotePath}: ${result.stderr}`)
274
- } else {
275
- logSuccess(`Deleted remote file: ${absoluteRemotePath}`)
276
- }
277
- }
278
-
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { NodeSSH } from 'node-ssh'
5
+ import chalk from 'chalk'
6
+
7
+ // Import utility functions - these need to be passed in or redefined to avoid circular dependency
8
+ // For now, we'll redefine the simple ones and accept others as parameters
9
+ const logProcessing = (message = '') => console.log(chalk.yellow(message))
10
+ const logSuccess = (message = '') => console.log(chalk.green(message))
11
+ const logError = (message = '') => console.error(chalk.red(message))
12
+ const logWarning = (message = '') => console.warn(chalk.yellow(message))
13
+
14
+ function expandHomePath(targetPath) {
15
+ if (!targetPath) {
16
+ return targetPath
17
+ }
18
+ if (targetPath.startsWith('~')) {
19
+ return path.join(os.homedir(), targetPath.slice(1))
20
+ }
21
+ return targetPath
22
+ }
23
+
24
+ async function resolveSshKeyPath(targetPath) {
25
+ const expanded = expandHomePath(targetPath)
26
+ try {
27
+ await fs.access(expanded)
28
+ } catch (error) {
29
+ throw new Error(`SSH key not accessible at ${expanded}`)
30
+ }
31
+ return expanded
32
+ }
33
+
34
+ function resolveRemotePath(projectPath, remoteHome) {
35
+ if (!projectPath) {
36
+ return projectPath
37
+ }
38
+ const sanitizedHome = remoteHome.replace(/\/+$/, '')
39
+ if (projectPath === '~') {
40
+ return sanitizedHome
41
+ }
42
+ if (projectPath.startsWith('~/')) {
43
+ const remainder = projectPath.slice(2)
44
+ return remainder ? `${sanitizedHome}/${remainder}` : sanitizedHome
45
+ }
46
+ if (projectPath.startsWith('/')) {
47
+ return projectPath
48
+ }
49
+ return `${sanitizedHome}/${projectPath}`
50
+ }
51
+
52
+ const createSshClient = () => {
53
+ if (typeof globalThis !== 'undefined' && globalThis.__zephyrSSHFactory) {
54
+ return globalThis.__zephyrSSHFactory()
55
+ }
56
+ return new NodeSSH()
57
+ }
58
+
59
+ // writeToLogFile will be passed as an optional parameter to avoid circular dependency
60
+
61
+ /**
62
+ * Connect to server via SSH
63
+ * @param {Object} config - Server configuration with sshUser, sshKey, serverIp, projectPath
64
+ * @param {string} rootDir - Local root directory for logging
65
+ * @returns {Promise<{ssh: NodeSSH, remoteCwd: string, remoteHome: string}>}
66
+ */
67
+ export async function connectToServer(config, rootDir) {
68
+ const ssh = createSshClient()
69
+ const sshUser = config.sshUser || os.userInfo().username
70
+ const privateKeyPath = await resolveSshKeyPath(config.sshKey)
71
+ const privateKey = await fs.readFile(privateKeyPath, 'utf8')
72
+
73
+ logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
74
+
75
+ await ssh.connect({
76
+ host: config.serverIp,
77
+ username: sshUser,
78
+ privateKey
79
+ })
80
+
81
+ const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
82
+ const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
83
+ const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
84
+
85
+ logSuccess(`Connected to ${config.serverIp}. Working directory: ${remoteCwd}`)
86
+
87
+ return { ssh, remoteCwd, remoteHome }
88
+ }
89
+
90
+ /**
91
+ * Execute a remote command with logging
92
+ * @param {NodeSSH} ssh - SSH client instance
93
+ * @param {string} label - Human-readable label for the command
94
+ * @param {string} command - Command to execute
95
+ * @param {Object} options - Options: { cwd, allowFailure, printStdout, bootstrapEnv, rootDir, writeToLogFile, env }
96
+ * @returns {Promise<Object>} Command result
97
+ */
98
+ export async function executeRemoteCommand(ssh, label, command, options = {}) {
99
+ const { cwd, allowFailure = false, printStdout = true, bootstrapEnv = true, rootDir = null, writeToLogFile = null, env = {} } = options
100
+
101
+ logProcessing(`\n→ ${label}`)
102
+
103
+ // Robust environment bootstrap for non-interactive shells
104
+ const profileBootstrap = [
105
+ 'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile"; fi',
106
+ 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile"; fi',
107
+ 'if [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi',
108
+ 'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile"; fi',
109
+ 'if [ -f "$HOME/.zshrc" ]; then . "$HOME/.zshrc"; fi',
110
+ 'if [ -s "$HOME/.nvm/nvm.sh" ]; then . "$HOME/.nvm/nvm.sh"; fi',
111
+ 'if [ -s "$HOME/.config/nvm/nvm.sh" ]; then . "$HOME/.config/nvm/nvm.sh"; fi',
112
+ 'if [ -s "/usr/local/opt/nvm/nvm.sh" ]; then . "/usr/local/opt/nvm/nvm.sh"; fi',
113
+ 'if command -v npm >/dev/null 2>&1; then :',
114
+ 'elif [ -d "$HOME/.nvm/versions/node" ]; then NODE_VERSION=$(ls -1 "$HOME/.nvm/versions/node" | tail -1) && export PATH="$HOME/.nvm/versions/node/$NODE_VERSION/bin:$PATH"',
115
+ 'elif [ -d "/usr/local/lib/node_modules/npm/bin" ]; then export PATH="/usr/local/lib/node_modules/npm/bin:$PATH"',
116
+ 'elif [ -d "/opt/homebrew/bin" ] && [ -f "/opt/homebrew/bin/npm" ]; then export PATH="/opt/homebrew/bin:$PATH"',
117
+ 'elif [ -d "/usr/local/bin" ] && [ -f "/usr/local/bin/npm" ]; then export PATH="/usr/local/bin:$PATH"',
118
+ 'elif [ -d "$HOME/.local/bin" ] && [ -f "$HOME/.local/bin/npm" ]; then export PATH="$HOME/.local/bin:$PATH"',
119
+ 'fi'
120
+ ].join('; ')
121
+
122
+ const escapeForDoubleQuotes = (value) => value.replace(/(["\\$`])/g, '\\$1')
123
+ const escapeForSingleQuotes = (value) => value.replace(/'/g, "'\\''")
124
+
125
+ // Build environment variable exports
126
+ let envExports = ''
127
+ if (Object.keys(env).length > 0) {
128
+ const envPairs = Object.entries(env).map(([key, value]) => {
129
+ const escapedValue = escapeForSingleQuotes(String(value))
130
+ return `${key}='${escapedValue}'`
131
+ })
132
+ envExports = envPairs.join(' ') + ' '
133
+ }
134
+
135
+ let wrappedCommand = command
136
+ let execOptions = { cwd }
137
+
138
+ if (bootstrapEnv && cwd) {
139
+ const cwdForShell = escapeForDoubleQuotes(cwd)
140
+ wrappedCommand = `${profileBootstrap}; cd "${cwdForShell}" && ${envExports}${command}`
141
+ execOptions = {}
142
+ } else if (Object.keys(env).length > 0) {
143
+ wrappedCommand = `${envExports}${command}`
144
+ }
145
+
146
+ const result = await ssh.execCommand(wrappedCommand, execOptions)
147
+
148
+ // Log to file if writeToLogFile function provided
149
+ if (writeToLogFile && rootDir) {
150
+ if (result.stdout && result.stdout.trim()) {
151
+ await writeToLogFile(rootDir, `[${label}] STDOUT:\n${result.stdout.trim()}`)
152
+ }
153
+ if (result.stderr && result.stderr.trim()) {
154
+ await writeToLogFile(rootDir, `[${label}] STDERR:\n${result.stderr.trim()}`)
155
+ }
156
+ }
157
+
158
+ // Show errors in terminal
159
+ if (result.code !== 0) {
160
+ if (result.stdout && result.stdout.trim()) {
161
+ logError(`\n[${label}] Output:\n${result.stdout.trim()}`)
162
+ }
163
+ if (result.stderr && result.stderr.trim()) {
164
+ logError(`\n[${label}] Error:\n${result.stderr.trim()}`)
165
+ }
166
+ }
167
+
168
+ if (result.code !== 0 && !allowFailure) {
169
+ const stderr = result.stderr?.trim() ?? ''
170
+ if (/command not found/.test(stderr) || /is not recognized/.test(stderr)) {
171
+ throw new Error(
172
+ `Command failed: ${command}. Ensure the remote environment loads required tools for non-interactive shells (e.g. export PATH in profile scripts).`
173
+ )
174
+ }
175
+ throw new Error(`Command failed: ${command}`)
176
+ }
177
+
178
+ // Show success confirmation
179
+ if (result.code === 0) {
180
+ logSuccess(`✓ ${command}`)
181
+ }
182
+
183
+ return result
184
+ }
185
+
186
+ /**
187
+ * Read file content from remote server
188
+ * @param {NodeSSH} ssh - SSH client instance
189
+ * @param {string} filePath - Path to file on remote server
190
+ * @param {string} remoteCwd - Remote working directory
191
+ * @returns {Promise<string>} File content
192
+ */
193
+ export async function readRemoteFile(ssh, filePath, remoteCwd) {
194
+ const escapedPath = filePath.replace(/'/g, "'\\''")
195
+ const command = `cat '${escapedPath}'`
196
+
197
+ const result = await ssh.execCommand(command, { cwd: remoteCwd })
198
+
199
+ if (result.code !== 0) {
200
+ throw new Error(`Failed to read remote file ${filePath}: ${result.stderr}`)
201
+ }
202
+
203
+ return result.stdout
204
+ }
205
+
206
+ /**
207
+ * Download file from remote server via SFTP with progress
208
+ *
209
+ * Note: Currently uses single-stream download (most reliable).
210
+ * Multi-streaming is technically possible with ssh2-sftp-client's fastGet,
211
+ * but it's unreliable on many servers and can cause data corruption.
212
+ * Single-stream ensures data integrity at the cost of potentially slower speeds.
213
+ *
214
+ * @param {NodeSSH} ssh - SSH client instance
215
+ * @param {string} remotePath - Path to file on remote server
216
+ * @param {string} localPath - Local path to save file
217
+ * @param {string} remoteCwd - Remote working directory (for relative paths)
218
+ * @returns {Promise<void>}
219
+ */
220
+ export async function downloadRemoteFile(ssh, remotePath, localPath, remoteCwd) {
221
+ // Resolve absolute path if relative
222
+ const absoluteRemotePath = remotePath.startsWith('/')
223
+ ? remotePath
224
+ : `${remoteCwd}/${remotePath}`
225
+
226
+ logProcessing(`Downloading ${absoluteRemotePath} to ${localPath}...`)
227
+
228
+ let transferred = 0
229
+ const startTime = Date.now()
230
+
231
+ // Single-stream download (most reliable for data integrity)
232
+ await ssh.getFile(localPath, absoluteRemotePath, null, {
233
+ step: (totalTransferred, chunk, total) => {
234
+ transferred = totalTransferred
235
+ const percent = total > 0 ? Math.round((transferred / total) * 100) : 0
236
+ const elapsed = (Date.now() - startTime) / 1000
237
+ const speed = elapsed > 0 ? (transferred / elapsed / 1024 / 1024).toFixed(2) : 0
238
+ const sizeMB = (transferred / 1024 / 1024).toFixed(2)
239
+ const totalMB = total > 0 ? (total / 1024 / 1024).toFixed(2) : '?'
240
+
241
+ // Update progress on same line
242
+ process.stdout.write(`\r Progress: ${percent}% (${sizeMB}MB / ${totalMB}MB) - ${speed} MB/s`)
243
+ }
244
+ })
245
+
246
+ // Clear progress line and show completion
247
+ process.stdout.write('\r' + ' '.repeat(80) + '\r')
248
+ logSuccess(`Downloaded ${absoluteRemotePath} to ${localPath}`)
249
+ }
250
+
251
+ /**
252
+ * Delete file from remote server
253
+ * @param {NodeSSH} ssh - SSH client instance
254
+ * @param {string} remotePath - Path to file on remote server
255
+ * @param {string} remoteCwd - Remote working directory (for relative paths)
256
+ * @returns {Promise<void>}
257
+ */
258
+ export async function deleteRemoteFile(ssh, remotePath, remoteCwd) {
259
+ // Resolve absolute path if relative
260
+ const absoluteRemotePath = remotePath.startsWith('/')
261
+ ? remotePath
262
+ : `${remoteCwd}/${remotePath}`
263
+
264
+ const escapedPath = absoluteRemotePath.replace(/'/g, "'\\''")
265
+ const command = `rm -f '${escapedPath}'`
266
+
267
+ logProcessing(`Deleting remote file: ${absoluteRemotePath}...`)
268
+
269
+ const result = await ssh.execCommand(command, { cwd: remoteCwd })
270
+
271
+ if (result.code !== 0 && result.code !== 1) {
272
+ // Exit code 1 is OK for rm -f (file doesn't exist)
273
+ logWarning(`Failed to delete remote file ${absoluteRemotePath}: ${result.stderr}`)
274
+ } else {
275
+ logSuccess(`Deleted remote file: ${absoluteRemotePath}`)
276
+ }
277
+ }
278
+