@wyxos/zephyr 0.2.21 → 0.2.22
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 +144 -144
- package/bin/zephyr.mjs +29 -29
- package/package.json +58 -58
- package/src/config/project.mjs +118 -118
- package/src/config/servers.mjs +57 -57
- package/src/dependency-scanner.mjs +412 -433
- package/src/deploy/local-repo.mjs +215 -215
- package/src/deploy/locks.mjs +171 -171
- package/src/deploy/preflight.mjs +117 -117
- package/src/deploy/remote-exec.mjs +99 -99
- package/src/deploy/snapshots.mjs +35 -35
- package/src/index.mjs +91 -91
- package/src/main.mjs +677 -652
- package/src/project/bootstrap.mjs +147 -147
- package/src/runtime/local-command.mjs +18 -18
- package/src/runtime/prompt.mjs +14 -14
- package/src/runtime/ssh-client.mjs +14 -14
- package/src/ssh/index.mjs +8 -8
- package/src/ssh/keys.mjs +146 -146
- package/src/ssh/ssh.mjs +134 -134
- package/src/utils/command.mjs +92 -92
- package/src/utils/config-flow.mjs +284 -284
- package/src/utils/git.mjs +91 -91
- package/src/utils/id.mjs +6 -6
- package/src/utils/log-file.mjs +76 -76
- package/src/utils/output.mjs +29 -29
- package/src/utils/paths.mjs +28 -28
- package/src/utils/php-version.mjs +137 -0
- package/src/utils/remote-path.mjs +23 -23
- package/src/utils/task-planner.mjs +99 -96
- package/src/version-checker.mjs +162 -162
package/src/ssh/keys.mjs
CHANGED
|
@@ -1,146 +1,146 @@
|
|
|
1
|
-
import fs from 'node:fs/promises'
|
|
2
|
-
import os from 'node:os'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
import inquirer from 'inquirer'
|
|
5
|
-
|
|
6
|
-
export async function isPrivateKeyFile(filePath) {
|
|
7
|
-
try {
|
|
8
|
-
const content = await fs.readFile(filePath, 'utf8')
|
|
9
|
-
return /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(content)
|
|
10
|
-
} catch (_error) {
|
|
11
|
-
return false
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export async function listSshKeys() {
|
|
16
|
-
const sshDir = path.join(os.homedir(), '.ssh')
|
|
17
|
-
|
|
18
|
-
try {
|
|
19
|
-
const entries = await fs.readdir(sshDir, { withFileTypes: true })
|
|
20
|
-
|
|
21
|
-
const candidates = entries
|
|
22
|
-
.filter((entry) => entry.isFile())
|
|
23
|
-
.map((entry) => entry.name)
|
|
24
|
-
.filter((name) => {
|
|
25
|
-
if (!name) return false
|
|
26
|
-
if (name.startsWith('.')) return false
|
|
27
|
-
if (name.endsWith('.pub')) return false
|
|
28
|
-
if (name.startsWith('known_hosts')) return false
|
|
29
|
-
if (name === 'config') return false
|
|
30
|
-
return name.trim().length > 0
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
const keys = []
|
|
34
|
-
|
|
35
|
-
for (const name of candidates) {
|
|
36
|
-
const filePath = path.join(sshDir, name)
|
|
37
|
-
if (await isPrivateKeyFile(filePath)) {
|
|
38
|
-
keys.push(name)
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return { sshDir, keys }
|
|
43
|
-
} catch (error) {
|
|
44
|
-
if (error.code === 'ENOENT') {
|
|
45
|
-
return { sshDir, keys: [] }
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
throw error
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export async function promptSshDetails(currentDir, existing = {}, { runPrompt } = {}) {
|
|
53
|
-
if (!runPrompt) {
|
|
54
|
-
throw new Error('promptSshDetails requires runPrompt')
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const { sshDir, keys: sshKeys } = await listSshKeys()
|
|
58
|
-
const defaultUser = existing.sshUser || os.userInfo().username
|
|
59
|
-
const fallbackKey = path.join(sshDir, 'id_rsa')
|
|
60
|
-
const preselectedKey = existing.sshKey || (sshKeys.length ? path.join(sshDir, sshKeys[0]) : fallbackKey)
|
|
61
|
-
|
|
62
|
-
const sshKeyPrompt = sshKeys.length
|
|
63
|
-
? {
|
|
64
|
-
type: 'list',
|
|
65
|
-
name: 'sshKeySelection',
|
|
66
|
-
message: 'SSH key',
|
|
67
|
-
choices: [
|
|
68
|
-
...sshKeys.map((key) => ({ name: key, value: path.join(sshDir, key) })),
|
|
69
|
-
new inquirer.Separator(),
|
|
70
|
-
{ name: 'Enter custom SSH key path…', value: '__custom' }
|
|
71
|
-
],
|
|
72
|
-
default: preselectedKey
|
|
73
|
-
}
|
|
74
|
-
: {
|
|
75
|
-
type: 'input',
|
|
76
|
-
name: 'sshKeySelection',
|
|
77
|
-
message: 'SSH key path',
|
|
78
|
-
default: preselectedKey
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const answers = await runPrompt([
|
|
82
|
-
{
|
|
83
|
-
type: 'input',
|
|
84
|
-
name: 'sshUser',
|
|
85
|
-
message: 'SSH user',
|
|
86
|
-
default: defaultUser
|
|
87
|
-
},
|
|
88
|
-
sshKeyPrompt
|
|
89
|
-
])
|
|
90
|
-
|
|
91
|
-
let sshKey = answers.sshKeySelection
|
|
92
|
-
|
|
93
|
-
if (sshKey === '__custom') {
|
|
94
|
-
const { customSshKey } = await runPrompt([
|
|
95
|
-
{
|
|
96
|
-
type: 'input',
|
|
97
|
-
name: 'customSshKey',
|
|
98
|
-
message: 'SSH key path',
|
|
99
|
-
default: preselectedKey
|
|
100
|
-
}
|
|
101
|
-
])
|
|
102
|
-
|
|
103
|
-
sshKey = customSshKey.trim() || preselectedKey
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
sshUser: answers.sshUser.trim() || defaultUser,
|
|
108
|
-
sshKey: sshKey.trim() || preselectedKey
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export async function ensureSshDetails(config, currentDir, { runPrompt, logProcessing } = {}) {
|
|
113
|
-
if (config.sshUser && config.sshKey) {
|
|
114
|
-
return false
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
logProcessing?.('SSH details missing. Please provide them now.')
|
|
118
|
-
const details = await promptSshDetails(currentDir, config, { runPrompt })
|
|
119
|
-
Object.assign(config, details)
|
|
120
|
-
return true
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export function expandHomePath(targetPath) {
|
|
124
|
-
if (!targetPath) {
|
|
125
|
-
return targetPath
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (targetPath.startsWith('~')) {
|
|
129
|
-
return path.join(os.homedir(), targetPath.slice(1))
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return targetPath
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export async function resolveSshKeyPath(targetPath) {
|
|
136
|
-
const expanded = expandHomePath(targetPath)
|
|
137
|
-
|
|
138
|
-
try {
|
|
139
|
-
await fs.access(expanded)
|
|
140
|
-
} catch (_error) {
|
|
141
|
-
throw new Error(`SSH key not accessible at ${expanded}`)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return expanded
|
|
145
|
-
}
|
|
146
|
-
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import inquirer from 'inquirer'
|
|
5
|
+
|
|
6
|
+
export async function isPrivateKeyFile(filePath) {
|
|
7
|
+
try {
|
|
8
|
+
const content = await fs.readFile(filePath, 'utf8')
|
|
9
|
+
return /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(content)
|
|
10
|
+
} catch (_error) {
|
|
11
|
+
return false
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function listSshKeys() {
|
|
16
|
+
const sshDir = path.join(os.homedir(), '.ssh')
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const entries = await fs.readdir(sshDir, { withFileTypes: true })
|
|
20
|
+
|
|
21
|
+
const candidates = entries
|
|
22
|
+
.filter((entry) => entry.isFile())
|
|
23
|
+
.map((entry) => entry.name)
|
|
24
|
+
.filter((name) => {
|
|
25
|
+
if (!name) return false
|
|
26
|
+
if (name.startsWith('.')) return false
|
|
27
|
+
if (name.endsWith('.pub')) return false
|
|
28
|
+
if (name.startsWith('known_hosts')) return false
|
|
29
|
+
if (name === 'config') return false
|
|
30
|
+
return name.trim().length > 0
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const keys = []
|
|
34
|
+
|
|
35
|
+
for (const name of candidates) {
|
|
36
|
+
const filePath = path.join(sshDir, name)
|
|
37
|
+
if (await isPrivateKeyFile(filePath)) {
|
|
38
|
+
keys.push(name)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { sshDir, keys }
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error.code === 'ENOENT') {
|
|
45
|
+
return { sshDir, keys: [] }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
throw error
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function promptSshDetails(currentDir, existing = {}, { runPrompt } = {}) {
|
|
53
|
+
if (!runPrompt) {
|
|
54
|
+
throw new Error('promptSshDetails requires runPrompt')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { sshDir, keys: sshKeys } = await listSshKeys()
|
|
58
|
+
const defaultUser = existing.sshUser || os.userInfo().username
|
|
59
|
+
const fallbackKey = path.join(sshDir, 'id_rsa')
|
|
60
|
+
const preselectedKey = existing.sshKey || (sshKeys.length ? path.join(sshDir, sshKeys[0]) : fallbackKey)
|
|
61
|
+
|
|
62
|
+
const sshKeyPrompt = sshKeys.length
|
|
63
|
+
? {
|
|
64
|
+
type: 'list',
|
|
65
|
+
name: 'sshKeySelection',
|
|
66
|
+
message: 'SSH key',
|
|
67
|
+
choices: [
|
|
68
|
+
...sshKeys.map((key) => ({ name: key, value: path.join(sshDir, key) })),
|
|
69
|
+
new inquirer.Separator(),
|
|
70
|
+
{ name: 'Enter custom SSH key path…', value: '__custom' }
|
|
71
|
+
],
|
|
72
|
+
default: preselectedKey
|
|
73
|
+
}
|
|
74
|
+
: {
|
|
75
|
+
type: 'input',
|
|
76
|
+
name: 'sshKeySelection',
|
|
77
|
+
message: 'SSH key path',
|
|
78
|
+
default: preselectedKey
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const answers = await runPrompt([
|
|
82
|
+
{
|
|
83
|
+
type: 'input',
|
|
84
|
+
name: 'sshUser',
|
|
85
|
+
message: 'SSH user',
|
|
86
|
+
default: defaultUser
|
|
87
|
+
},
|
|
88
|
+
sshKeyPrompt
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
let sshKey = answers.sshKeySelection
|
|
92
|
+
|
|
93
|
+
if (sshKey === '__custom') {
|
|
94
|
+
const { customSshKey } = await runPrompt([
|
|
95
|
+
{
|
|
96
|
+
type: 'input',
|
|
97
|
+
name: 'customSshKey',
|
|
98
|
+
message: 'SSH key path',
|
|
99
|
+
default: preselectedKey
|
|
100
|
+
}
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
sshKey = customSshKey.trim() || preselectedKey
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
sshUser: answers.sshUser.trim() || defaultUser,
|
|
108
|
+
sshKey: sshKey.trim() || preselectedKey
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function ensureSshDetails(config, currentDir, { runPrompt, logProcessing } = {}) {
|
|
113
|
+
if (config.sshUser && config.sshKey) {
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
logProcessing?.('SSH details missing. Please provide them now.')
|
|
118
|
+
const details = await promptSshDetails(currentDir, config, { runPrompt })
|
|
119
|
+
Object.assign(config, details)
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function expandHomePath(targetPath) {
|
|
124
|
+
if (!targetPath) {
|
|
125
|
+
return targetPath
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (targetPath.startsWith('~')) {
|
|
129
|
+
return path.join(os.homedir(), targetPath.slice(1))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return targetPath
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function resolveSshKeyPath(targetPath) {
|
|
136
|
+
const expanded = expandHomePath(targetPath)
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await fs.access(expanded)
|
|
140
|
+
} catch (_error) {
|
|
141
|
+
throw new Error(`SSH key not accessible at ${expanded}`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return expanded
|
|
145
|
+
}
|
|
146
|
+
|
package/src/ssh/ssh.mjs
CHANGED
|
@@ -1,134 +1,134 @@
|
|
|
1
|
-
import fs from 'node:fs/promises'
|
|
2
|
-
import os from 'node:os'
|
|
3
|
-
import chalk from 'chalk'
|
|
4
|
-
import process from 'node:process'
|
|
5
|
-
import { NodeSSH } from 'node-ssh'
|
|
6
|
-
import { createChalkLogger } from '../utils/output.mjs'
|
|
7
|
-
import { resolveRemotePath } from '../utils/remote-path.mjs'
|
|
8
|
-
import { resolveSshKeyPath } from './keys.mjs'
|
|
9
|
-
import { createRemoteExecutor } from '../deploy/remote-exec.mjs'
|
|
10
|
-
import { createSshClientFactory } from '../runtime/ssh-client.mjs'
|
|
11
|
-
|
|
12
|
-
const { logProcessing, logSuccess, logWarning, logError } = createChalkLogger(chalk)
|
|
13
|
-
|
|
14
|
-
const createSshClient = createSshClientFactory({ NodeSSH })
|
|
15
|
-
|
|
16
|
-
function normalizeRemotePath(value) {
|
|
17
|
-
if (value == null) return value
|
|
18
|
-
return String(value).replace(/\\/g, '/')
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export async function connectToServer(config) {
|
|
22
|
-
const ssh = createSshClient()
|
|
23
|
-
const sshUser = config.sshUser || os.userInfo().username
|
|
24
|
-
const privateKeyPath = await resolveSshKeyPath(config.sshKey)
|
|
25
|
-
const privateKey = await fs.readFile(privateKeyPath, 'utf8')
|
|
26
|
-
|
|
27
|
-
logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
|
|
28
|
-
|
|
29
|
-
await ssh.connect({
|
|
30
|
-
host: config.serverIp,
|
|
31
|
-
username: sshUser,
|
|
32
|
-
privateKey
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
|
|
36
|
-
const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
|
|
37
|
-
const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
|
|
38
|
-
|
|
39
|
-
logSuccess(`Connected to ${config.serverIp}. Working directory: ${remoteCwd}`)
|
|
40
|
-
|
|
41
|
-
return { ssh, remoteCwd, remoteHome }
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export async function executeRemoteCommand(ssh, label, command, options = {}) {
|
|
45
|
-
const {
|
|
46
|
-
cwd,
|
|
47
|
-
allowFailure = false,
|
|
48
|
-
bootstrapEnv = true,
|
|
49
|
-
rootDir = null,
|
|
50
|
-
writeToLogFile = null,
|
|
51
|
-
env = {}
|
|
52
|
-
// printStdout: legacy option, intentionally ignored (we log to file)
|
|
53
|
-
} = options
|
|
54
|
-
|
|
55
|
-
const rootDirForLogging = rootDir ?? process.cwd()
|
|
56
|
-
const writeToLogFileFn = writeToLogFile ?? (async () => {})
|
|
57
|
-
|
|
58
|
-
const executeRemote = createRemoteExecutor({
|
|
59
|
-
ssh,
|
|
60
|
-
rootDir: rootDirForLogging,
|
|
61
|
-
remoteCwd: cwd,
|
|
62
|
-
writeToLogFile: writeToLogFileFn,
|
|
63
|
-
logProcessing,
|
|
64
|
-
logSuccess,
|
|
65
|
-
logError
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
return await executeRemote(label, command, { cwd, allowFailure, bootstrapEnv, env })
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export async function readRemoteFile(ssh, filePath, remoteCwd) {
|
|
72
|
-
const normalizedPath = normalizeRemotePath(filePath)
|
|
73
|
-
const escapedPath = normalizedPath.replace(/'/g, "'\\''")
|
|
74
|
-
const command = `cat '${escapedPath}'`
|
|
75
|
-
|
|
76
|
-
const result = await ssh.execCommand(command, { cwd: normalizeRemotePath(remoteCwd) })
|
|
77
|
-
|
|
78
|
-
if (result.code !== 0) {
|
|
79
|
-
throw new Error(`Failed to read remote file ${filePath}: ${result.stderr}`)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return result.stdout
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export async function downloadRemoteFile(ssh, remotePath, localPath, remoteCwd) {
|
|
86
|
-
const normalizedRemotePath = normalizeRemotePath(remotePath)
|
|
87
|
-
const normalizedCwd = normalizeRemotePath(remoteCwd)
|
|
88
|
-
const absoluteRemotePath = normalizedRemotePath.startsWith('/')
|
|
89
|
-
? normalizedRemotePath
|
|
90
|
-
: `${normalizedCwd}/${normalizedRemotePath}`
|
|
91
|
-
|
|
92
|
-
logProcessing(`Downloading ${absoluteRemotePath} to ${localPath}...`)
|
|
93
|
-
|
|
94
|
-
let transferred = 0
|
|
95
|
-
const startTime = Date.now()
|
|
96
|
-
|
|
97
|
-
await ssh.getFile(localPath, absoluteRemotePath, null, {
|
|
98
|
-
step: (totalTransferred, _chunk, total) => {
|
|
99
|
-
transferred = totalTransferred
|
|
100
|
-
const percent = total > 0 ? Math.round((transferred / total) * 100) : 0
|
|
101
|
-
const elapsed = (Date.now() - startTime) / 1000
|
|
102
|
-
const speed = elapsed > 0 ? (transferred / elapsed / 1024 / 1024).toFixed(2) : 0
|
|
103
|
-
const sizeMB = (transferred / 1024 / 1024).toFixed(2)
|
|
104
|
-
const totalMB = total > 0 ? (total / 1024 / 1024).toFixed(2) : '?'
|
|
105
|
-
|
|
106
|
-
process.stdout.write(`\r Progress: ${percent}% (${sizeMB}MB / ${totalMB}MB) - ${speed} MB/s`)
|
|
107
|
-
}
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
process.stdout.write('\r' + ' '.repeat(80) + '\r')
|
|
111
|
-
logSuccess(`Downloaded ${absoluteRemotePath} to ${localPath}`)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export async function deleteRemoteFile(ssh, remotePath, remoteCwd) {
|
|
115
|
-
const normalizedRemotePath = normalizeRemotePath(remotePath)
|
|
116
|
-
const normalizedCwd = normalizeRemotePath(remoteCwd)
|
|
117
|
-
const absoluteRemotePath = normalizedRemotePath.startsWith('/')
|
|
118
|
-
? normalizedRemotePath
|
|
119
|
-
: `${normalizedCwd}/${normalizedRemotePath}`
|
|
120
|
-
|
|
121
|
-
const escapedPath = absoluteRemotePath.replace(/'/g, "'\\''")
|
|
122
|
-
const command = `rm -f '${escapedPath}'`
|
|
123
|
-
|
|
124
|
-
logProcessing(`Deleting remote file: ${absoluteRemotePath}...`)
|
|
125
|
-
|
|
126
|
-
const result = await ssh.execCommand(command, { cwd: normalizedCwd })
|
|
127
|
-
|
|
128
|
-
if (result.code !== 0 && result.code !== 1) {
|
|
129
|
-
logWarning(`Failed to delete remote file ${absoluteRemotePath}: ${result.stderr}`)
|
|
130
|
-
} else {
|
|
131
|
-
logSuccess(`Deleted remote file: ${absoluteRemotePath}`)
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
import { NodeSSH } from 'node-ssh'
|
|
6
|
+
import { createChalkLogger } from '../utils/output.mjs'
|
|
7
|
+
import { resolveRemotePath } from '../utils/remote-path.mjs'
|
|
8
|
+
import { resolveSshKeyPath } from './keys.mjs'
|
|
9
|
+
import { createRemoteExecutor } from '../deploy/remote-exec.mjs'
|
|
10
|
+
import { createSshClientFactory } from '../runtime/ssh-client.mjs'
|
|
11
|
+
|
|
12
|
+
const { logProcessing, logSuccess, logWarning, logError } = createChalkLogger(chalk)
|
|
13
|
+
|
|
14
|
+
const createSshClient = createSshClientFactory({ NodeSSH })
|
|
15
|
+
|
|
16
|
+
function normalizeRemotePath(value) {
|
|
17
|
+
if (value == null) return value
|
|
18
|
+
return String(value).replace(/\\/g, '/')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function connectToServer(config) {
|
|
22
|
+
const ssh = createSshClient()
|
|
23
|
+
const sshUser = config.sshUser || os.userInfo().username
|
|
24
|
+
const privateKeyPath = await resolveSshKeyPath(config.sshKey)
|
|
25
|
+
const privateKey = await fs.readFile(privateKeyPath, 'utf8')
|
|
26
|
+
|
|
27
|
+
logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
|
|
28
|
+
|
|
29
|
+
await ssh.connect({
|
|
30
|
+
host: config.serverIp,
|
|
31
|
+
username: sshUser,
|
|
32
|
+
privateKey
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
|
|
36
|
+
const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
|
|
37
|
+
const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
|
|
38
|
+
|
|
39
|
+
logSuccess(`Connected to ${config.serverIp}. Working directory: ${remoteCwd}`)
|
|
40
|
+
|
|
41
|
+
return { ssh, remoteCwd, remoteHome }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function executeRemoteCommand(ssh, label, command, options = {}) {
|
|
45
|
+
const {
|
|
46
|
+
cwd,
|
|
47
|
+
allowFailure = false,
|
|
48
|
+
bootstrapEnv = true,
|
|
49
|
+
rootDir = null,
|
|
50
|
+
writeToLogFile = null,
|
|
51
|
+
env = {}
|
|
52
|
+
// printStdout: legacy option, intentionally ignored (we log to file)
|
|
53
|
+
} = options
|
|
54
|
+
|
|
55
|
+
const rootDirForLogging = rootDir ?? process.cwd()
|
|
56
|
+
const writeToLogFileFn = writeToLogFile ?? (async () => {})
|
|
57
|
+
|
|
58
|
+
const executeRemote = createRemoteExecutor({
|
|
59
|
+
ssh,
|
|
60
|
+
rootDir: rootDirForLogging,
|
|
61
|
+
remoteCwd: cwd,
|
|
62
|
+
writeToLogFile: writeToLogFileFn,
|
|
63
|
+
logProcessing,
|
|
64
|
+
logSuccess,
|
|
65
|
+
logError
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return await executeRemote(label, command, { cwd, allowFailure, bootstrapEnv, env })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function readRemoteFile(ssh, filePath, remoteCwd) {
|
|
72
|
+
const normalizedPath = normalizeRemotePath(filePath)
|
|
73
|
+
const escapedPath = normalizedPath.replace(/'/g, "'\\''")
|
|
74
|
+
const command = `cat '${escapedPath}'`
|
|
75
|
+
|
|
76
|
+
const result = await ssh.execCommand(command, { cwd: normalizeRemotePath(remoteCwd) })
|
|
77
|
+
|
|
78
|
+
if (result.code !== 0) {
|
|
79
|
+
throw new Error(`Failed to read remote file ${filePath}: ${result.stderr}`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result.stdout
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function downloadRemoteFile(ssh, remotePath, localPath, remoteCwd) {
|
|
86
|
+
const normalizedRemotePath = normalizeRemotePath(remotePath)
|
|
87
|
+
const normalizedCwd = normalizeRemotePath(remoteCwd)
|
|
88
|
+
const absoluteRemotePath = normalizedRemotePath.startsWith('/')
|
|
89
|
+
? normalizedRemotePath
|
|
90
|
+
: `${normalizedCwd}/${normalizedRemotePath}`
|
|
91
|
+
|
|
92
|
+
logProcessing(`Downloading ${absoluteRemotePath} to ${localPath}...`)
|
|
93
|
+
|
|
94
|
+
let transferred = 0
|
|
95
|
+
const startTime = Date.now()
|
|
96
|
+
|
|
97
|
+
await ssh.getFile(localPath, absoluteRemotePath, null, {
|
|
98
|
+
step: (totalTransferred, _chunk, total) => {
|
|
99
|
+
transferred = totalTransferred
|
|
100
|
+
const percent = total > 0 ? Math.round((transferred / total) * 100) : 0
|
|
101
|
+
const elapsed = (Date.now() - startTime) / 1000
|
|
102
|
+
const speed = elapsed > 0 ? (transferred / elapsed / 1024 / 1024).toFixed(2) : 0
|
|
103
|
+
const sizeMB = (transferred / 1024 / 1024).toFixed(2)
|
|
104
|
+
const totalMB = total > 0 ? (total / 1024 / 1024).toFixed(2) : '?'
|
|
105
|
+
|
|
106
|
+
process.stdout.write(`\r Progress: ${percent}% (${sizeMB}MB / ${totalMB}MB) - ${speed} MB/s`)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
process.stdout.write('\r' + ' '.repeat(80) + '\r')
|
|
111
|
+
logSuccess(`Downloaded ${absoluteRemotePath} to ${localPath}`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function deleteRemoteFile(ssh, remotePath, remoteCwd) {
|
|
115
|
+
const normalizedRemotePath = normalizeRemotePath(remotePath)
|
|
116
|
+
const normalizedCwd = normalizeRemotePath(remoteCwd)
|
|
117
|
+
const absoluteRemotePath = normalizedRemotePath.startsWith('/')
|
|
118
|
+
? normalizedRemotePath
|
|
119
|
+
: `${normalizedCwd}/${normalizedRemotePath}`
|
|
120
|
+
|
|
121
|
+
const escapedPath = absoluteRemotePath.replace(/'/g, "'\\''")
|
|
122
|
+
const command = `rm -f '${escapedPath}'`
|
|
123
|
+
|
|
124
|
+
logProcessing(`Deleting remote file: ${absoluteRemotePath}...`)
|
|
125
|
+
|
|
126
|
+
const result = await ssh.execCommand(command, { cwd: normalizedCwd })
|
|
127
|
+
|
|
128
|
+
if (result.code !== 0 && result.code !== 1) {
|
|
129
|
+
logWarning(`Failed to delete remote file ${absoluteRemotePath}: ${result.stderr}`)
|
|
130
|
+
} else {
|
|
131
|
+
logSuccess(`Deleted remote file: ${absoluteRemotePath}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|