@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.
- package/README.md +104 -104
- package/bin/zephyr.mjs +12 -12
- package/package.json +53 -53
- package/src/index.mjs +2121 -2121
- package/src/release-node.mjs +613 -670
- package/src/release-packagist.mjs +17 -3
- package/src/ssh-utils.mjs +278 -278
|
@@ -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
|
-
|
|
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
|
+
|