@phystack/cli 4.5.19-dev → 4.5.20-dev
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/dist/commands/app/create.js +11 -5
- package/dist/commands/app/create.js.map +1 -1
- package/dist/commands/app/types.js +1 -0
- package/dist/commands/app/types.js.map +1 -1
- package/dist/commands/dev/cp.js +764 -0
- package/dist/commands/dev/cp.js.map +1 -0
- package/dist/commands/dev/forward.js +15 -1
- package/dist/commands/dev/forward.js.map +1 -1
- package/dist/commands/dev/index.js +14 -0
- package/dist/commands/dev/index.js.map +1 -1
- package/dist/commands/dev/shell.js +17 -2
- package/dist/commands/dev/shell.js.map +1 -1
- package/dist/commands/flash.js +1 -1
- package/dist/commands/legacydev/forward.js +1 -1
- package/dist/commands/legacydev/forward.js.map +1 -1
- package/dist/commands/legacydev/shell.js +1 -1
- package/dist/commands/legacydev/shell.js.map +1 -1
- package/dist/commands/vm/create.js +265 -24
- package/dist/commands/vm/create.js.map +1 -1
- package/dist/services/auth/device-grant-auth.service.js +1 -1
- package/dist/services/auth/device-grant-auth.service.js.map +1 -1
- package/dist/utils/docker-credentials.js +1 -1
- package/dist/utils/docker-credentials.js.map +1 -1
- package/dist/utils/emulated-device.js +1 -1
- package/dist/utils/emulated-device.js.map +1 -1
- package/dist/utils/registry-credentials.js +1 -1
- package/dist/utils/registry-credentials.js.map +1 -1
- package/dist/utils/tenant-storage.js +1 -1
- package/dist/utils/tenant-storage.js.map +1 -1
- package/dist/utils/vm.js +2 -2
- package/dist/utils/vm.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.default = cp;
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const os_1 = __importDefault(require("os"));
|
|
12
|
+
const net_1 = __importDefault(require("net"));
|
|
13
|
+
const utils_1 = require("../../utils");
|
|
14
|
+
const admin_api_1 = require("../../services/admin-api");
|
|
15
|
+
/**
|
|
16
|
+
* Checks if a string is an IP address
|
|
17
|
+
*/
|
|
18
|
+
const isIpAddress = (name) => {
|
|
19
|
+
const octetPattern = '(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)';
|
|
20
|
+
const ipPattern = new RegExp(`^${octetPattern}\\.${octetPattern}\\.${octetPattern}\\.${octetPattern}$`);
|
|
21
|
+
return ipPattern.test(name);
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Parses a device path string (e.g., "device-name:/path/to/file")
|
|
25
|
+
*/
|
|
26
|
+
function parseDevicePath(devicePath) {
|
|
27
|
+
const colonIndex = devicePath.indexOf(':');
|
|
28
|
+
if (colonIndex === -1) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const deviceName = devicePath.substring(0, colonIndex);
|
|
32
|
+
const remotePath = devicePath.substring(colonIndex + 1);
|
|
33
|
+
if (!deviceName || !remotePath) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return { deviceName, remotePath };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* File copy utility using SSH tunnel with base64 encoding for reliable binary transfer.
|
|
40
|
+
*/
|
|
41
|
+
class FileCopy {
|
|
42
|
+
constructor(debug = false) {
|
|
43
|
+
this.keyfile = '';
|
|
44
|
+
this.debug = false;
|
|
45
|
+
this.debug = debug;
|
|
46
|
+
}
|
|
47
|
+
debugLog(message) {
|
|
48
|
+
if (this.debug) {
|
|
49
|
+
console.log(chalk_1.default.dim(message));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async copyFromDevice(deviceName, remotePath, localPath) {
|
|
53
|
+
return new Promise(async (resolve, reject) => {
|
|
54
|
+
console.log(chalk_1.default.blue(`Copying from device: ${deviceName}`));
|
|
55
|
+
console.log(chalk_1.default.dim(` Remote: ${remotePath}`));
|
|
56
|
+
console.log(chalk_1.default.dim(` Local: ${localPath}`));
|
|
57
|
+
let targetPath = localPath;
|
|
58
|
+
if (fs_1.default.existsSync(localPath) && fs_1.default.statSync(localPath).isDirectory()) {
|
|
59
|
+
const remoteFileName = path_1.default.basename(remotePath);
|
|
60
|
+
targetPath = path_1.default.join(localPath, remoteFileName);
|
|
61
|
+
console.log(chalk_1.default.dim(` Target: ${targetPath}`));
|
|
62
|
+
}
|
|
63
|
+
const localDir = path_1.default.dirname(targetPath);
|
|
64
|
+
if (!fs_1.default.existsSync(localDir)) {
|
|
65
|
+
fs_1.default.mkdirSync(localDir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
this.keyfile = path_1.default.join(os_1.default.tmpdir(), `gridapp-cp-${Math.floor(Math.random() * 1000)}.key`);
|
|
68
|
+
if (fs_1.default.existsSync(this.keyfile)) {
|
|
69
|
+
fs_1.default.unlinkSync(this.keyfile);
|
|
70
|
+
}
|
|
71
|
+
if (fs_1.default.existsSync(`${this.keyfile}.pub`)) {
|
|
72
|
+
fs_1.default.unlinkSync(`${this.keyfile}.pub`);
|
|
73
|
+
}
|
|
74
|
+
(0, child_process_1.execSync)(`ssh-keygen -t ed25519 -f ${this.keyfile} -N ""`, { stdio: 'ignore' });
|
|
75
|
+
const key = fs_1.default.readFileSync(`${this.keyfile}.pub`).toString();
|
|
76
|
+
let stream;
|
|
77
|
+
let isPhyos = true;
|
|
78
|
+
try {
|
|
79
|
+
this.debugLog('[DEBUG] Starting PhyHub connection...');
|
|
80
|
+
const { stream: curStream, isPhyos: isPhyosDevice } = (0, utils_1.openStream)(await (0, admin_api_1.adminApi)().devices.startPhyHubDeviceShellSession(deviceName, { key }));
|
|
81
|
+
stream = curStream;
|
|
82
|
+
isPhyos = isPhyosDevice;
|
|
83
|
+
this.debugLog(`[DEBUG] PhyHub stream connected, isPhyos: ${isPhyos}`);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
if (this.debug) {
|
|
87
|
+
console.log(chalk_1.default.red(`[DEBUG] PhyHub connection failed: ${err.message}`));
|
|
88
|
+
}
|
|
89
|
+
if (err.message && err.message.includes('Unauthorized')) {
|
|
90
|
+
reject(new Error(`Unauthorized. Please run "phy login" to use this command.`));
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
reject(new Error(`Unable to establish connection to the device. ${err}`));
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const port = Math.floor(10000 + ((Math.random() * 100000) % 10000));
|
|
98
|
+
this.debugLog(`[DEBUG] Creating local TCP server on port ${port}...`);
|
|
99
|
+
let tcpServer = null;
|
|
100
|
+
const connPromise = new Promise((resolveConn) => {
|
|
101
|
+
tcpServer = net_1.default.createServer((client) => {
|
|
102
|
+
this.debugLog('[DEBUG] TCP client connected');
|
|
103
|
+
if (tcpServer) {
|
|
104
|
+
tcpServer.close();
|
|
105
|
+
tcpServer = null;
|
|
106
|
+
}
|
|
107
|
+
resolveConn(client);
|
|
108
|
+
});
|
|
109
|
+
tcpServer.listen(port, () => {
|
|
110
|
+
this.debugLog(`[DEBUG] TCP server listening on port ${port}`);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
this.debugLog(`[DEBUG] Spawning SSH tunnel to ${isPhyos ? 'phystack' : 'ombori'}@localhost:${port}...`);
|
|
114
|
+
const sshTunnel = (0, child_process_1.spawn)('ssh', [
|
|
115
|
+
'-qtt',
|
|
116
|
+
`${isPhyos ? 'phystack' : 'ombori'}@localhost`,
|
|
117
|
+
'-p',
|
|
118
|
+
`${port}`,
|
|
119
|
+
'-o',
|
|
120
|
+
'UserKnownHostsFile=/dev/null',
|
|
121
|
+
'-o',
|
|
122
|
+
'StrictHostKeyChecking=no',
|
|
123
|
+
'-o',
|
|
124
|
+
'ServerAliveInterval=30',
|
|
125
|
+
'-i',
|
|
126
|
+
this.keyfile,
|
|
127
|
+
], {
|
|
128
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
129
|
+
detached: false,
|
|
130
|
+
});
|
|
131
|
+
const conn = await connPromise;
|
|
132
|
+
this.debugLog('[DEBUG] TCP connection established, piping streams...');
|
|
133
|
+
conn.pipe(stream);
|
|
134
|
+
stream.pipe(conn);
|
|
135
|
+
this.debugLog('[DEBUG] Tunnel established, waiting for shell...');
|
|
136
|
+
const fileStream = fs_1.default.createWriteStream(targetPath);
|
|
137
|
+
let outputBuffer = '';
|
|
138
|
+
let menuHandled = false;
|
|
139
|
+
let commandSent = false;
|
|
140
|
+
let dataReceived = false;
|
|
141
|
+
let bytesWritten = 0;
|
|
142
|
+
let transferComplete = false;
|
|
143
|
+
let resolved = false; // Flag to prevent multiple resolutions
|
|
144
|
+
let expectedFileSize = null;
|
|
145
|
+
let base64Buffer = ''; // Buffer for accumulating base64 data
|
|
146
|
+
sshTunnel.stdout.on('data', (data) => {
|
|
147
|
+
const output = data.toString();
|
|
148
|
+
if (!dataReceived) {
|
|
149
|
+
outputBuffer += output;
|
|
150
|
+
}
|
|
151
|
+
if (!menuHandled) {
|
|
152
|
+
if (outputBuffer.includes('Press ENTER to open the menu') ||
|
|
153
|
+
output.includes('Press ENTER to open the menu')) {
|
|
154
|
+
this.debugLog('[DEBUG] Detected menu prompt, sending ENTER...');
|
|
155
|
+
sshTunnel.stdin?.write('\n');
|
|
156
|
+
outputBuffer = '';
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (outputBuffer.includes('Select an option') ||
|
|
160
|
+
output.includes('Select an option') ||
|
|
161
|
+
outputBuffer.includes('Shell (Open shell)') ||
|
|
162
|
+
output.includes('Shell (Open shell)') ||
|
|
163
|
+
outputBuffer.includes('Main menu') ||
|
|
164
|
+
output.includes('Main menu')) {
|
|
165
|
+
menuHandled = true;
|
|
166
|
+
this.debugLog('[DEBUG] Detected menu, selecting shell...');
|
|
167
|
+
sshTunnel.stdin?.write('s\n');
|
|
168
|
+
outputBuffer = '';
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (!commandSent) {
|
|
174
|
+
const promptPattern = /[a-zA-Z0-9_-]+@[a-zA-Z0-9_.-]+[:~]*[\$#]\s*$/;
|
|
175
|
+
const veryEnd = outputBuffer.slice(-50);
|
|
176
|
+
const promptAtVeryEnd = veryEnd.match(promptPattern);
|
|
177
|
+
if (promptAtVeryEnd) {
|
|
178
|
+
commandSent = true;
|
|
179
|
+
this.debugLog('[DEBUG] Shell prompt detected, getting file size...');
|
|
180
|
+
outputBuffer = '';
|
|
181
|
+
process.stdout.write(`Progress: 0% (starting download...)\r`);
|
|
182
|
+
const escapedPath = remotePath.replace(/'/g, "'\\''");
|
|
183
|
+
sshTunnel.stdin?.write(`stat -c%s '${escapedPath}' 2>/dev/null || echo 0\n`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (commandSent) {
|
|
188
|
+
const outputStr = output.toString();
|
|
189
|
+
if (expectedFileSize === null && !dataReceived) {
|
|
190
|
+
const sizeMatch = outputStr.match(/^(\d+)\s*$/m);
|
|
191
|
+
if (sizeMatch) {
|
|
192
|
+
expectedFileSize = parseInt(sizeMatch[1], 10);
|
|
193
|
+
this.debugLog(`[DEBUG] Expected file size: ${expectedFileSize} bytes`);
|
|
194
|
+
const escapedPath = remotePath.replace(/'/g, "'\\''");
|
|
195
|
+
sshTunnel.stdin?.write(`base64 < '${escapedPath}'\n`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const promptPattern = /[a-zA-Z0-9_-]+@[a-zA-Z0-9_.-]+[:~]*[\$#]\s*$/;
|
|
199
|
+
if (promptPattern.test(outputStr) && outputStr.trim().length < 100) {
|
|
200
|
+
this.debugLog('[DEBUG] No file size received, proceeding with base64...');
|
|
201
|
+
const escapedPath = remotePath.replace(/'/g, "'\\''");
|
|
202
|
+
sshTunnel.stdin?.write(`base64 < '${escapedPath}'\n`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (expectedFileSize !== null) {
|
|
207
|
+
dataReceived = true;
|
|
208
|
+
const escapedPath = remotePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
209
|
+
let cleanBase64 = outputStr
|
|
210
|
+
.replace(/stat.*?\n/g, '')
|
|
211
|
+
.replace(new RegExp(`base64.*?${escapedPath}.*?\\n?`, 'gi'), '') // Remove command echo with path
|
|
212
|
+
.replace(/base64.*?<.*?\\n?/g, '') // Remove "base64 < 'path'" patterns
|
|
213
|
+
.replace(/base64.*?\\n?/g, '') // Remove any remaining "base64" command echoes
|
|
214
|
+
.replace(/[a-zA-Z0-9_-]+@[a-zA-Z0-9_.-]+[:~]*[\$#]\s*/g, '')
|
|
215
|
+
.replace(/[\r\n]+$/, '')
|
|
216
|
+
.replace(/^[\r\n]+/, '');
|
|
217
|
+
cleanBase64 = cleanBase64.replace(/[^A-Za-z0-9+\/=]/g, '');
|
|
218
|
+
if (cleanBase64.length > 0) {
|
|
219
|
+
base64Buffer += cleanBase64;
|
|
220
|
+
const minVerifyLength = 8;
|
|
221
|
+
if (base64Buffer.length >= minVerifyLength && bytesWritten === 0) {
|
|
222
|
+
let foundStart = false;
|
|
223
|
+
for (let offset = 0; offset <= Math.min(20, base64Buffer.length - minVerifyLength); offset += 4) {
|
|
224
|
+
try {
|
|
225
|
+
const testChunk = base64Buffer.slice(offset, offset + 8);
|
|
226
|
+
const testDecoded = Buffer.from(testChunk, 'base64');
|
|
227
|
+
if (testDecoded.length >= 4) {
|
|
228
|
+
const magic = testDecoded.slice(0, 4);
|
|
229
|
+
if (magic[0] === 0x7f &&
|
|
230
|
+
magic[1] === 0x45 &&
|
|
231
|
+
magic[2] === 0x4c &&
|
|
232
|
+
magic[3] === 0x46) {
|
|
233
|
+
this.debugLog(`[DEBUG] Found ELF magic at offset ${offset}, removing ${offset} chars of garbage`);
|
|
234
|
+
base64Buffer = base64Buffer.slice(offset);
|
|
235
|
+
foundStart = true;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
this.debugLog(`[DEBUG] Error searching for ELF magic: ${error}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!foundStart && base64Buffer.length > 50) {
|
|
245
|
+
if (this.debug) {
|
|
246
|
+
console.log(chalk_1.default.yellow(`[DEBUG] Warning: Could not find ELF magic in first ${base64Buffer.length} base64 chars`));
|
|
247
|
+
this.debugLog(`[DEBUG] First 50 chars: ${base64Buffer.slice(0, 50)}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
else if (foundStart) {
|
|
251
|
+
this.debugLog(`[DEBUG] Note: Removed ${base64Buffer.length === 0 ? 'garbage' : 'initial garbage'} from base64 stream`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const decodeLength = Math.floor(base64Buffer.length / 4) * 4;
|
|
255
|
+
if (decodeLength > 0) {
|
|
256
|
+
try {
|
|
257
|
+
const toDecode = base64Buffer.slice(0, decodeLength);
|
|
258
|
+
const decoded = Buffer.from(toDecode, 'base64');
|
|
259
|
+
if (bytesWritten === 0 && decoded.length >= 4) {
|
|
260
|
+
const firstBytes = Array.from(decoded.slice(0, 4))
|
|
261
|
+
.map((byte) => '0x' + byte.toString(16).padStart(2, '0'))
|
|
262
|
+
.join(' ');
|
|
263
|
+
this.debugLog(`[DEBUG] First decoded bytes: ${firstBytes} (should be 0x7f 0x45 0x4c 0x46 for ELF)`);
|
|
264
|
+
this.debugLog(`[DEBUG] First 20 base64 chars were: ${toDecode.slice(0, 20)}`);
|
|
265
|
+
}
|
|
266
|
+
if (decoded.length > 0) {
|
|
267
|
+
if (bytesWritten + decoded.length >= expectedFileSize) {
|
|
268
|
+
const bytesToWrite = expectedFileSize - bytesWritten;
|
|
269
|
+
if (bytesToWrite > 0) {
|
|
270
|
+
this.debugLog(`[DEBUG] Reached expected size, truncating (writing ${bytesToWrite} of ${decoded.length} bytes, total: ${bytesWritten +
|
|
271
|
+
bytesToWrite}/${expectedFileSize})`);
|
|
272
|
+
fileStream.write(decoded.slice(0, bytesToWrite));
|
|
273
|
+
bytesWritten = expectedFileSize;
|
|
274
|
+
transferComplete = true;
|
|
275
|
+
fileStream.end();
|
|
276
|
+
if (!resolved) {
|
|
277
|
+
resolved = true;
|
|
278
|
+
process.stdout.write('\r' + ' '.repeat(80) + '\r');
|
|
279
|
+
stream.destroy();
|
|
280
|
+
conn.destroy();
|
|
281
|
+
if (tcpServer) {
|
|
282
|
+
tcpServer.close();
|
|
283
|
+
tcpServer = null;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
sshTunnel.kill('SIGTERM');
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
this.debugLog(`[DEBUG] Error killing SSH process: ${error}`);
|
|
290
|
+
}
|
|
291
|
+
setTimeout(() => {
|
|
292
|
+
try {
|
|
293
|
+
if (!sshTunnel.killed) {
|
|
294
|
+
sshTunnel.kill('SIGKILL');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
this.debugLog(`[DEBUG] Error force killing SSH process: ${error}`);
|
|
299
|
+
}
|
|
300
|
+
}, 100);
|
|
301
|
+
console.log(chalk_1.default.green('✓ Copy completed successfully'));
|
|
302
|
+
resolve();
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
if (!resolved) {
|
|
308
|
+
resolved = true;
|
|
309
|
+
transferComplete = true;
|
|
310
|
+
fileStream.end();
|
|
311
|
+
setTimeout(() => {
|
|
312
|
+
sshTunnel.kill();
|
|
313
|
+
stream.destroy();
|
|
314
|
+
console.log(chalk_1.default.green('✓ Copy completed successfully'));
|
|
315
|
+
resolve();
|
|
316
|
+
}, 100);
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
fileStream.write(decoded);
|
|
323
|
+
bytesWritten += decoded.length;
|
|
324
|
+
const percentage = (bytesWritten / expectedFileSize) * 100;
|
|
325
|
+
const lastLoggedPercentage = Math.floor(((bytesWritten - decoded.length) / expectedFileSize) * 100);
|
|
326
|
+
const currentPercentage = Math.floor(percentage);
|
|
327
|
+
if (currentPercentage !== lastLoggedPercentage &&
|
|
328
|
+
currentPercentage % 5 === 0) {
|
|
329
|
+
process.stdout.write(`\rProgress: ${currentPercentage.toFixed(0)}% (${bytesWritten}/${expectedFileSize} bytes)`);
|
|
330
|
+
}
|
|
331
|
+
else if (bytesWritten % 102400 < decoded.length) {
|
|
332
|
+
this.debugLog(`[DEBUG] Written ${bytesWritten}/${expectedFileSize} bytes (${percentage.toFixed(1)}%)`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
base64Buffer = base64Buffer.slice(decodeLength);
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
if (this.debug) {
|
|
340
|
+
console.log(chalk_1.default.red(`[DEBUG] Base64 decode error: ${err}`));
|
|
341
|
+
this.debugLog(`[DEBUG] Base64 buffer length: ${base64Buffer.length}, decodeLength: ${decodeLength}`);
|
|
342
|
+
this.debugLog(`[DEBUG] First 100 chars of buffer: ${base64Buffer.slice(0, 100)}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const promptPattern = /[a-zA-Z0-9_-]+@[a-zA-Z0-9_.-]+[:~]*[\$#]\s*$/;
|
|
348
|
+
if (promptPattern.test(outputStr) &&
|
|
349
|
+
bytesWritten >= expectedFileSize &&
|
|
350
|
+
!transferComplete &&
|
|
351
|
+
!resolved) {
|
|
352
|
+
this.debugLog('[DEBUG] Detected prompt after expected size, transfer complete');
|
|
353
|
+
if (!resolved) {
|
|
354
|
+
resolved = true;
|
|
355
|
+
transferComplete = true;
|
|
356
|
+
fileStream.end();
|
|
357
|
+
// Clear progress line and show completion
|
|
358
|
+
process.stdout.write('\r' + ' '.repeat(80) + '\r');
|
|
359
|
+
// Destroy streams immediately
|
|
360
|
+
stream.destroy();
|
|
361
|
+
conn.destroy();
|
|
362
|
+
if (tcpServer) {
|
|
363
|
+
tcpServer.close();
|
|
364
|
+
tcpServer = null;
|
|
365
|
+
}
|
|
366
|
+
// Kill SSH process immediately
|
|
367
|
+
try {
|
|
368
|
+
sshTunnel.kill('SIGTERM');
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
this.debugLog(`[DEBUG] Error killing SSH process: ${error}`);
|
|
372
|
+
}
|
|
373
|
+
setTimeout(() => {
|
|
374
|
+
try {
|
|
375
|
+
if (!sshTunnel.killed) {
|
|
376
|
+
sshTunnel.kill('SIGKILL');
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
this.debugLog(`[DEBUG] Error force killing SSH process: ${error}`);
|
|
381
|
+
}
|
|
382
|
+
}, 100);
|
|
383
|
+
console.log(chalk_1.default.green('✓ Copy completed successfully'));
|
|
384
|
+
resolve();
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
sshTunnel.stderr.on('data', (data) => {
|
|
393
|
+
const output = data.toString();
|
|
394
|
+
if (!output.includes('phyhub') && !output.includes('connect()')) {
|
|
395
|
+
if (output.trim()) {
|
|
396
|
+
this.debugLog(`[DEBUG] SSH stderr: ${output.substring(0, 200)}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
sshTunnel.on('error', (error) => {
|
|
401
|
+
if (this.debug) {
|
|
402
|
+
console.log(chalk_1.default.red(`[DEBUG] SSH error: ${error.message}`));
|
|
403
|
+
}
|
|
404
|
+
fileStream.close();
|
|
405
|
+
stream.destroy();
|
|
406
|
+
reject(error);
|
|
407
|
+
});
|
|
408
|
+
setTimeout(() => {
|
|
409
|
+
if (!commandSent) {
|
|
410
|
+
sshTunnel.kill();
|
|
411
|
+
fileStream.close();
|
|
412
|
+
stream.destroy();
|
|
413
|
+
reject(new Error('Timeout waiting for shell prompt'));
|
|
414
|
+
}
|
|
415
|
+
}, 300000);
|
|
416
|
+
sshTunnel.on('close', (code) => {
|
|
417
|
+
this.debugLog(`[DEBUG] SSH process closed with code ${code}`);
|
|
418
|
+
if (!resolved) {
|
|
419
|
+
if (!fileStream.destroyed) {
|
|
420
|
+
fileStream.end();
|
|
421
|
+
}
|
|
422
|
+
stream.destroy();
|
|
423
|
+
conn.destroy();
|
|
424
|
+
if (tcpServer) {
|
|
425
|
+
tcpServer.close();
|
|
426
|
+
tcpServer = null;
|
|
427
|
+
}
|
|
428
|
+
setTimeout(() => {
|
|
429
|
+
try {
|
|
430
|
+
if (fs_1.default.existsSync(this.keyfile)) {
|
|
431
|
+
fs_1.default.unlinkSync(this.keyfile);
|
|
432
|
+
}
|
|
433
|
+
if (fs_1.default.existsSync(`${this.keyfile}.pub`)) {
|
|
434
|
+
fs_1.default.unlinkSync(`${this.keyfile}.pub`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
this.debugLog(`[DEBUG] Error cleaning up key file: ${error}`);
|
|
439
|
+
}
|
|
440
|
+
}, 1000);
|
|
441
|
+
if (code !== 0 && code !== null && !commandSent) {
|
|
442
|
+
fileStream.close();
|
|
443
|
+
reject(new Error(`SSH exited with code ${code}`));
|
|
444
|
+
}
|
|
445
|
+
else if (!dataReceived) {
|
|
446
|
+
reject(new Error(`No data received. File may not exist or be empty.`));
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
setTimeout(() => {
|
|
450
|
+
try {
|
|
451
|
+
if (fs_1.default.existsSync(targetPath)) {
|
|
452
|
+
const fileContent = fs_1.default.readFileSync(targetPath);
|
|
453
|
+
if (expectedFileSize !== null &&
|
|
454
|
+
fileContent.length !== expectedFileSize) {
|
|
455
|
+
if (fileContent.length > expectedFileSize) {
|
|
456
|
+
const trimmed = fileContent.slice(0, expectedFileSize);
|
|
457
|
+
fs_1.default.writeFileSync(targetPath, trimmed);
|
|
458
|
+
this.debugLog(`[DEBUG] Trimmed file to expected size (${fileContent.length} -> ${expectedFileSize} bytes)`);
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
if (this.debug) {
|
|
462
|
+
console.log(chalk_1.default.yellow(`[DEBUG] Warning: File is ${expectedFileSize -
|
|
463
|
+
fileContent.length} bytes smaller than expected`));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
this.debugLog(`[DEBUG] Error in final cleanup: ${err}`);
|
|
471
|
+
}
|
|
472
|
+
if (!resolved) {
|
|
473
|
+
resolved = true;
|
|
474
|
+
console.log(chalk_1.default.green('✓ Copy completed successfully'));
|
|
475
|
+
resolve();
|
|
476
|
+
}
|
|
477
|
+
}, 200);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
async copyToDevice(deviceName, localPath, remotePath) {
|
|
484
|
+
return new Promise(async (resolve, reject) => {
|
|
485
|
+
console.log(chalk_1.default.blue(`Copying to device: ${deviceName}`));
|
|
486
|
+
console.log(chalk_1.default.dim(` Local: ${localPath}`));
|
|
487
|
+
console.log(chalk_1.default.dim(` Remote: ${remotePath}`));
|
|
488
|
+
if (!fs_1.default.existsSync(localPath)) {
|
|
489
|
+
reject(new Error(`Local file not found: ${localPath}`));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
let targetRemotePath = remotePath;
|
|
493
|
+
if (remotePath.endsWith('/')) {
|
|
494
|
+
const localFileName = path_1.default.basename(localPath);
|
|
495
|
+
targetRemotePath = path_1.default.join(remotePath, localFileName).replace(/\\/g, '/');
|
|
496
|
+
console.log(chalk_1.default.dim(` Target: ${targetRemotePath}`));
|
|
497
|
+
}
|
|
498
|
+
this.keyfile = path_1.default.join(os_1.default.tmpdir(), `gridapp-cp-${Math.floor(Math.random() * 1000)}.key`);
|
|
499
|
+
if (fs_1.default.existsSync(this.keyfile)) {
|
|
500
|
+
fs_1.default.unlinkSync(this.keyfile);
|
|
501
|
+
}
|
|
502
|
+
if (fs_1.default.existsSync(`${this.keyfile}.pub`)) {
|
|
503
|
+
fs_1.default.unlinkSync(`${this.keyfile}.pub`);
|
|
504
|
+
}
|
|
505
|
+
(0, child_process_1.execSync)(`ssh-keygen -t ed25519 -f ${this.keyfile} -N ""`, { stdio: 'ignore' });
|
|
506
|
+
const key = fs_1.default.readFileSync(`${this.keyfile}.pub`).toString();
|
|
507
|
+
let stream;
|
|
508
|
+
let isPhyos = true;
|
|
509
|
+
try {
|
|
510
|
+
this.debugLog('[DEBUG] Starting PhyHub connection...');
|
|
511
|
+
const { stream: curStream, isPhyos: isPhyosDevice } = (0, utils_1.openStream)(await (0, admin_api_1.adminApi)().devices.startPhyHubDeviceShellSession(deviceName, { key }));
|
|
512
|
+
stream = curStream;
|
|
513
|
+
isPhyos = isPhyosDevice;
|
|
514
|
+
this.debugLog(`[DEBUG] PhyHub stream connected, isPhyos: ${isPhyos}`);
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
if (this.debug) {
|
|
518
|
+
console.log(chalk_1.default.red(`[DEBUG] PhyHub connection failed: ${err.message}`));
|
|
519
|
+
}
|
|
520
|
+
if (err.message && err.message.includes('Unauthorized')) {
|
|
521
|
+
reject(new Error(`Unauthorized. Please run "phy login" to use this command.`));
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
reject(new Error(`Unable to establish connection to the device. ${err}`));
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const port = Math.floor(10000 + ((Math.random() * 100000) % 10000));
|
|
529
|
+
this.debugLog(`[DEBUG] Creating local TCP server on port ${port}...`);
|
|
530
|
+
let tcpServer = null;
|
|
531
|
+
const connPromise = new Promise((resolveConn) => {
|
|
532
|
+
tcpServer = net_1.default.createServer((client) => {
|
|
533
|
+
this.debugLog('[DEBUG] TCP client connected');
|
|
534
|
+
if (tcpServer) {
|
|
535
|
+
tcpServer.close();
|
|
536
|
+
tcpServer = null;
|
|
537
|
+
}
|
|
538
|
+
resolveConn(client);
|
|
539
|
+
});
|
|
540
|
+
tcpServer.listen(port, () => {
|
|
541
|
+
this.debugLog(`[DEBUG] TCP server listening on port ${port}`);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
this.debugLog(`[DEBUG] Spawning SSH tunnel to ${isPhyos ? 'phystack' : 'ombori'}@localhost:${port}...`);
|
|
545
|
+
const sshTunnel = (0, child_process_1.spawn)('ssh', [
|
|
546
|
+
'-qtt',
|
|
547
|
+
`${isPhyos ? 'phystack' : 'ombori'}@localhost`,
|
|
548
|
+
'-p',
|
|
549
|
+
`${port}`,
|
|
550
|
+
'-o',
|
|
551
|
+
'UserKnownHostsFile=/dev/null',
|
|
552
|
+
'-o',
|
|
553
|
+
'StrictHostKeyChecking=no',
|
|
554
|
+
'-o',
|
|
555
|
+
'ServerAliveInterval=30',
|
|
556
|
+
'-i',
|
|
557
|
+
this.keyfile,
|
|
558
|
+
], {
|
|
559
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
560
|
+
detached: false,
|
|
561
|
+
});
|
|
562
|
+
const conn = await connPromise;
|
|
563
|
+
this.debugLog('[DEBUG] TCP connection established, piping streams...');
|
|
564
|
+
conn.pipe(stream);
|
|
565
|
+
stream.pipe(conn);
|
|
566
|
+
this.debugLog('[DEBUG] Tunnel established, waiting for shell...');
|
|
567
|
+
let outputBuffer = '';
|
|
568
|
+
let menuHandled = false;
|
|
569
|
+
let commandSent = false;
|
|
570
|
+
let transferComplete = false; // Flag to prevent multiple completions
|
|
571
|
+
let resolved = false; // Flag to prevent multiple resolutions
|
|
572
|
+
const fileContent = fs_1.default.readFileSync(localPath);
|
|
573
|
+
const fileSize = fileContent.length;
|
|
574
|
+
const fileBase64 = fileContent.toString('base64');
|
|
575
|
+
let bytesSent = 0; // Track bytes sent for progress
|
|
576
|
+
sshTunnel.stdout.on('data', (data) => {
|
|
577
|
+
const output = data.toString();
|
|
578
|
+
if (!commandSent) {
|
|
579
|
+
outputBuffer += output;
|
|
580
|
+
}
|
|
581
|
+
if (!menuHandled) {
|
|
582
|
+
if (outputBuffer.includes('Press ENTER to open the menu') ||
|
|
583
|
+
output.includes('Press ENTER to open the menu')) {
|
|
584
|
+
this.debugLog('[DEBUG] Detected menu prompt, sending ENTER...');
|
|
585
|
+
sshTunnel.stdin?.write('\n');
|
|
586
|
+
outputBuffer = '';
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (outputBuffer.includes('Select an option') ||
|
|
590
|
+
output.includes('Select an option') ||
|
|
591
|
+
outputBuffer.includes('Shell (Open shell)') ||
|
|
592
|
+
output.includes('Shell (Open shell)') ||
|
|
593
|
+
outputBuffer.includes('Main menu') ||
|
|
594
|
+
output.includes('Main menu')) {
|
|
595
|
+
menuHandled = true;
|
|
596
|
+
this.debugLog('[DEBUG] Detected menu, selecting shell...');
|
|
597
|
+
sshTunnel.stdin?.write('s\n');
|
|
598
|
+
outputBuffer = '';
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (!commandSent) {
|
|
604
|
+
const promptPattern = /[a-zA-Z0-9_-]+@[a-zA-Z0-9_.-]+[:~]*[\$#]\s*$/;
|
|
605
|
+
const veryEnd = outputBuffer.slice(-50);
|
|
606
|
+
const promptAtVeryEnd = veryEnd.match(promptPattern);
|
|
607
|
+
if (promptAtVeryEnd) {
|
|
608
|
+
commandSent = true;
|
|
609
|
+
this.debugLog('[DEBUG] Shell prompt detected, copying file...');
|
|
610
|
+
outputBuffer = '';
|
|
611
|
+
process.stdout.write(`Progress: 0% (sending file...)\r`);
|
|
612
|
+
const escapedPath = targetRemotePath.replace(/'/g, "'\\''");
|
|
613
|
+
const script = `cat > /tmp/cp_script.sh << 'EOFSCRIPT'
|
|
614
|
+
#!/bin/bash
|
|
615
|
+
echo '${fileBase64}' | base64 -d > '${escapedPath}'
|
|
616
|
+
EOFSCRIPT
|
|
617
|
+
chmod +x /tmp/cp_script.sh
|
|
618
|
+
/tmp/cp_script.sh
|
|
619
|
+
rm /tmp/cp_script.sh
|
|
620
|
+
echo "DONE"
|
|
621
|
+
`;
|
|
622
|
+
sshTunnel.stdin?.write(script);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (commandSent && output.includes('DONE') && !transferComplete) {
|
|
627
|
+
transferComplete = true;
|
|
628
|
+
this.debugLog('[DEBUG] File copy completed on remote side');
|
|
629
|
+
if (!resolved) {
|
|
630
|
+
resolved = true;
|
|
631
|
+
// Clear progress line and show completion
|
|
632
|
+
process.stdout.write('\r' + ' '.repeat(80) + '\r');
|
|
633
|
+
// Destroy streams immediately
|
|
634
|
+
stream.destroy();
|
|
635
|
+
conn.destroy();
|
|
636
|
+
if (tcpServer) {
|
|
637
|
+
tcpServer.close();
|
|
638
|
+
tcpServer = null;
|
|
639
|
+
}
|
|
640
|
+
try {
|
|
641
|
+
sshTunnel.kill('SIGTERM');
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
this.debugLog(`[DEBUG] Error killing SSH process: ${error}`);
|
|
645
|
+
}
|
|
646
|
+
setTimeout(() => {
|
|
647
|
+
try {
|
|
648
|
+
if (!sshTunnel.killed) {
|
|
649
|
+
sshTunnel.kill('SIGKILL');
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
this.debugLog(`[DEBUG] Error force killing SSH process: ${error}`);
|
|
654
|
+
}
|
|
655
|
+
}, 100);
|
|
656
|
+
console.log(chalk_1.default.green('✓ Copy completed successfully'));
|
|
657
|
+
resolve();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (commandSent &&
|
|
661
|
+
!transferComplete &&
|
|
662
|
+
output.length > 0 &&
|
|
663
|
+
!output.includes('DONE')) {
|
|
664
|
+
bytesSent += output.length;
|
|
665
|
+
const estimatedProgress = Math.min(95, (bytesSent / (fileBase64.length * 1.2)) * 100);
|
|
666
|
+
const lastProgress = Math.floor(((bytesSent - output.length) / (fileBase64.length * 1.2)) * 100);
|
|
667
|
+
const currentProgress = Math.floor(estimatedProgress);
|
|
668
|
+
if (currentProgress !== lastProgress && currentProgress % 5 === 0) {
|
|
669
|
+
process.stdout.write(`\rProgress: ${currentProgress.toFixed(0)}% (sending file...)`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
sshTunnel.stderr.on('data', (data) => {
|
|
674
|
+
const output = data.toString();
|
|
675
|
+
if (!output.includes('phyhub') && !output.includes('connect()')) {
|
|
676
|
+
if (output.trim()) {
|
|
677
|
+
this.debugLog(`[DEBUG] SSH stderr: ${output.substring(0, 200)}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
sshTunnel.on('error', (error) => {
|
|
682
|
+
if (this.debug) {
|
|
683
|
+
console.log(chalk_1.default.red(`[DEBUG] SSH error: ${error.message}`));
|
|
684
|
+
}
|
|
685
|
+
stream.destroy();
|
|
686
|
+
reject(error);
|
|
687
|
+
});
|
|
688
|
+
setTimeout(() => {
|
|
689
|
+
if (!commandSent) {
|
|
690
|
+
sshTunnel.kill();
|
|
691
|
+
stream.destroy();
|
|
692
|
+
reject(new Error('Timeout waiting for shell prompt'));
|
|
693
|
+
}
|
|
694
|
+
}, 300000);
|
|
695
|
+
sshTunnel.on('close', (code) => {
|
|
696
|
+
this.debugLog(`[DEBUG] SSH process closed with code ${code}`);
|
|
697
|
+
if (!resolved) {
|
|
698
|
+
stream.destroy();
|
|
699
|
+
conn.destroy();
|
|
700
|
+
if (tcpServer) {
|
|
701
|
+
tcpServer.close();
|
|
702
|
+
tcpServer = null;
|
|
703
|
+
}
|
|
704
|
+
setTimeout(() => {
|
|
705
|
+
try {
|
|
706
|
+
if (fs_1.default.existsSync(this.keyfile)) {
|
|
707
|
+
fs_1.default.unlinkSync(this.keyfile);
|
|
708
|
+
}
|
|
709
|
+
if (fs_1.default.existsSync(`${this.keyfile}.pub`)) {
|
|
710
|
+
fs_1.default.unlinkSync(`${this.keyfile}.pub`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
this.debugLog(`[DEBUG] Error cleaning up key file: ${error}`);
|
|
715
|
+
}
|
|
716
|
+
}, 1000);
|
|
717
|
+
if (code !== 0 && code !== null && !commandSent) {
|
|
718
|
+
reject(new Error(`SSH exited with code ${code}`));
|
|
719
|
+
}
|
|
720
|
+
else if (commandSent && !outputBuffer.includes('DONE') && !transferComplete) {
|
|
721
|
+
reject(new Error(`File copy may have failed. Check remote path: ${targetRemotePath}`));
|
|
722
|
+
}
|
|
723
|
+
else if (!resolved && transferComplete) {
|
|
724
|
+
resolved = true;
|
|
725
|
+
console.log(chalk_1.default.green('✓ Copy completed successfully'));
|
|
726
|
+
resolve();
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
async function cp(source, destination, options = {}) {
|
|
734
|
+
try {
|
|
735
|
+
const fileCopy = new FileCopy(options.verbose || false);
|
|
736
|
+
const sourceDevice = parseDevicePath(source);
|
|
737
|
+
if (sourceDevice) {
|
|
738
|
+
await fileCopy.copyFromDevice(sourceDevice.deviceName, sourceDevice.remotePath, destination);
|
|
739
|
+
setTimeout(() => {
|
|
740
|
+
process.exit(0);
|
|
741
|
+
}, 500);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const destDevice = parseDevicePath(destination);
|
|
745
|
+
if (destDevice) {
|
|
746
|
+
await fileCopy.copyToDevice(destDevice.deviceName, source, destDevice.remotePath);
|
|
747
|
+
setTimeout(() => {
|
|
748
|
+
process.exit(0);
|
|
749
|
+
}, 500);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
throw new Error('At least one path must be a device path (format: device-name:/path/to/file)');
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
console.error(chalk_1.default.red(`Failed to copy: ${error.message}`));
|
|
756
|
+
if (error.message.includes('file not found')) {
|
|
757
|
+
console.error(chalk_1.default.yellow('Hint: Make sure the file exists and you have permission to access it.'));
|
|
758
|
+
}
|
|
759
|
+
setTimeout(() => {
|
|
760
|
+
process.exit(1);
|
|
761
|
+
}, 500);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
//# sourceMappingURL=cp.js.map
|