@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/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
+