entexto-cli 1.4.3 → 1.4.5
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/bin/entexto.js +8 -0
- package/lib/commands/pull.js +6 -2
- package/lib/commands/sync.js +31 -9
- package/lib/commands/tunnel.js +158 -0
- package/package.json +3 -2
package/bin/entexto.js
CHANGED
|
@@ -77,6 +77,14 @@ program
|
|
|
77
77
|
.option('-f, --full', 'Subir todos los archivos en el delta inicial (sin comparación)')
|
|
78
78
|
.action(require('../lib/commands/sync'));
|
|
79
79
|
|
|
80
|
+
// ─── entexto tunnel ──────────────────────────────────────────
|
|
81
|
+
program
|
|
82
|
+
.command('tunnel [target]')
|
|
83
|
+
.description('Exponer un servidor local en una URL pública de entexto.com')
|
|
84
|
+
.action((target) => {
|
|
85
|
+
require('../lib/commands/tunnel')({ target: target || '3000' });
|
|
86
|
+
});
|
|
87
|
+
|
|
80
88
|
program.parse(process.argv);
|
|
81
89
|
|
|
82
90
|
if (!process.argv.slice(2).length) {
|
package/lib/commands/pull.js
CHANGED
|
@@ -85,13 +85,17 @@ module.exports = async function pull(options) {
|
|
|
85
85
|
|
|
86
86
|
// Escribir archivos localmente
|
|
87
87
|
let escritos = 0;
|
|
88
|
-
for (const { ruta, content } of data.archivos) {
|
|
88
|
+
for (const { ruta, content, isBinary } of data.archivos) {
|
|
89
89
|
const destFile = path.join(absDir, ruta);
|
|
90
90
|
const dir = path.dirname(destFile);
|
|
91
91
|
|
|
92
92
|
try {
|
|
93
93
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
94
|
-
|
|
94
|
+
if (isBinary) {
|
|
95
|
+
fs.writeFileSync(destFile, Buffer.from(content, 'base64'));
|
|
96
|
+
} else {
|
|
97
|
+
fs.writeFileSync(destFile, content, 'utf8');
|
|
98
|
+
}
|
|
95
99
|
escritos++;
|
|
96
100
|
} catch (err) {
|
|
97
101
|
console.warn(chalk.yellow(` ⚠ No se pudo escribir: ${ruta} — ${err.message}`));
|
package/lib/commands/sync.js
CHANGED
|
@@ -178,19 +178,24 @@ module.exports = async function sync(options) {
|
|
|
178
178
|
try {
|
|
179
179
|
const pullData = await pullProject(uuid);
|
|
180
180
|
const archivos = pullData.archivos || [];
|
|
181
|
-
const contentMap = new Map(archivos.map(a => [a.ruta, a
|
|
181
|
+
const contentMap = new Map(archivos.map(a => [a.ruta, a]));
|
|
182
182
|
sp.stop();
|
|
183
183
|
let downloaded = 0;
|
|
184
184
|
for (const ruta of aDescargar) {
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
185
|
+
const entrada = contentMap.get(ruta);
|
|
186
|
+
if (!entrada) {
|
|
187
187
|
console.log(' ' + chalk.yellow('⚠') + ' No encontrado en pull: ' + chalk.gray(ruta));
|
|
188
188
|
continue;
|
|
189
189
|
}
|
|
190
|
+
const contenido = entrada.content;
|
|
190
191
|
const destFile = path.join(absDir, ruta);
|
|
191
192
|
const destDir = path.dirname(destFile);
|
|
192
193
|
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
193
|
-
|
|
194
|
+
if (entrada.isBinary) {
|
|
195
|
+
fs.writeFileSync(destFile, Buffer.from(contenido, 'base64'));
|
|
196
|
+
} else {
|
|
197
|
+
fs.writeFileSync(destFile, contenido, 'utf8');
|
|
198
|
+
}
|
|
194
199
|
localUploadedAt.set(ruta, Date.now()); // evitar re-subida por chokidar
|
|
195
200
|
console.log(' ' + chalk.cyan('↓') + ' ' + chalk.white(ruta) + chalk.gray(' ← remoto'));
|
|
196
201
|
downloaded++;
|
|
@@ -233,11 +238,16 @@ module.exports = async function sync(options) {
|
|
|
233
238
|
|
|
234
239
|
// Reintenta una operación FS hasta maxRetries veces con delay ms entre intentos.
|
|
235
240
|
// Necesario en Windows donde los archivos abiertos en VS Code quedan bloqueados (EPERM/EBUSY).
|
|
236
|
-
|
|
241
|
+
// retryFsOp: reintenta una operación FS hasta maxRetries veces si el archivo
|
|
242
|
+
// está bloqueado (EBUSY/EPERM/EACCES). Si se agotan los intentos, llama a
|
|
243
|
+
// onFail() para que el caller pueda limpiar cualquier guard de estado
|
|
244
|
+
// (ej. localDeletedAt) y no quede desincronizado.
|
|
245
|
+
function retryFsOp(opName, ruta, fn, maxRetries, delay, onFail) {
|
|
237
246
|
let attempt = 0;
|
|
238
247
|
function tryOnce() {
|
|
239
248
|
try {
|
|
240
249
|
fn();
|
|
250
|
+
// Éxito: no se llama onFail
|
|
241
251
|
} catch (err) {
|
|
242
252
|
const locked = err.code === 'EBUSY' || err.code === 'EPERM' || err.code === 'EACCES';
|
|
243
253
|
if (locked && attempt < maxRetries) {
|
|
@@ -247,9 +257,11 @@ module.exports = async function sync(options) {
|
|
|
247
257
|
}
|
|
248
258
|
setTimeout(tryOnce, delay);
|
|
249
259
|
} else if (locked) {
|
|
250
|
-
console.log(' ' + chalk.red('✖') + ' ' + chalk.white(ruta) + chalk.red(' bloqueado — cierra el archivo
|
|
260
|
+
console.log(' ' + chalk.red('✖') + ' ' + chalk.white(ruta) + chalk.red(' bloqueado — cierra el archivo e intenta de nuevo'));
|
|
261
|
+
if (typeof onFail === 'function') onFail();
|
|
251
262
|
} else {
|
|
252
263
|
console.log(' ' + chalk.red('✖') + ' ' + opName + ' fallido (' + (err.code || err.message) + '): ' + ruta);
|
|
264
|
+
if (typeof onFail === 'function') onFail();
|
|
253
265
|
}
|
|
254
266
|
}
|
|
255
267
|
}
|
|
@@ -269,7 +281,7 @@ module.exports = async function sync(options) {
|
|
|
269
281
|
retryFsOp('borrar', ruta, () => {
|
|
270
282
|
fs.unlinkSync(destFile);
|
|
271
283
|
console.log(' ' + chalk.red('✖') + ' ' + chalk.white(ruta) + chalk.gray(' ← borrado remoto'));
|
|
272
|
-
}, 5, 500);
|
|
284
|
+
}, 5, 500, () => localDeletedAt.delete(ruta));
|
|
273
285
|
return;
|
|
274
286
|
}
|
|
275
287
|
if (eventType === 'file-rename') {
|
|
@@ -286,13 +298,23 @@ module.exports = async function sync(options) {
|
|
|
286
298
|
if (!fs.existsSync(newDir)) fs.mkdirSync(newDir, { recursive: true });
|
|
287
299
|
fs.renameSync(oldFile, newFile);
|
|
288
300
|
console.log(' ' + chalk.yellow('↔') + ' ' + chalk.white(ruta_antigua) + chalk.gray(' → ') + chalk.white(ruta_nueva));
|
|
289
|
-
}, 5, 500)
|
|
301
|
+
}, 5, 500, () => {
|
|
302
|
+
localDeletedAt.delete(ruta_antigua);
|
|
303
|
+
localUploadedAt.delete(ruta_nueva);
|
|
304
|
+
});
|
|
290
305
|
return;
|
|
291
306
|
}
|
|
292
307
|
if (eventType !== 'file-update') return;
|
|
293
|
-
const { ruta, contenido } = data || {};
|
|
308
|
+
const { ruta, contenido, isBinary, isText } = data || {};
|
|
294
309
|
if (!ruta || contenido === undefined) return;
|
|
295
310
|
|
|
311
|
+
// Binary files via SSE: skip (handled by full pull on reconnect)
|
|
312
|
+
const binary = isBinary === true || isText === false;
|
|
313
|
+
if (binary) {
|
|
314
|
+
console.log(' ' + chalk.gray('⊘') + ' ' + chalk.gray(ruta) + chalk.gray(' (binario, ignorado por SSE)'));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
296
318
|
// Evitar eco: si acabamos de subir este archivo (< 3s), ignorar
|
|
297
319
|
const uploadedAt = localUploadedAt.get(ruta);
|
|
298
320
|
if (uploadedAt && Date.now() - uploadedAt < 3000) return;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
const ora = require('ora');
|
|
7
|
+
|
|
8
|
+
module.exports = async function tunnel(opts) {
|
|
9
|
+
// Parsear target: puede ser un puerto, una URL, o un hostname
|
|
10
|
+
const target = opts.target || 'localhost:3000';
|
|
11
|
+
let targetUrl;
|
|
12
|
+
|
|
13
|
+
if (/^\d+$/.test(target)) {
|
|
14
|
+
targetUrl = `http://localhost:${target}`;
|
|
15
|
+
} else if (/^https?:\/\//.test(target)) {
|
|
16
|
+
targetUrl = target;
|
|
17
|
+
} else {
|
|
18
|
+
targetUrl = `http://${target}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Validar que la URL es parseable
|
|
22
|
+
let parsedTarget;
|
|
23
|
+
try {
|
|
24
|
+
parsedTarget = new URL(targetUrl);
|
|
25
|
+
} catch {
|
|
26
|
+
console.error(chalk.red(`URL inválida: ${target}`));
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { getConfig } = require('../utils/config');
|
|
31
|
+
const cfg = getConfig();
|
|
32
|
+
const baseUrl = (cfg.baseUrl || 'https://entexto.com').replace(/\/$/, '');
|
|
33
|
+
|
|
34
|
+
// Calcular URL del WebSocket
|
|
35
|
+
const wsUrl = baseUrl.replace(/^http/, 'ws') + '/tunnel';
|
|
36
|
+
|
|
37
|
+
const spinner = ora('Conectando al servidor de túneles...').start();
|
|
38
|
+
|
|
39
|
+
// Cargar socket.io-client
|
|
40
|
+
let ioClient;
|
|
41
|
+
try {
|
|
42
|
+
ioClient = require('socket.io-client');
|
|
43
|
+
} catch {
|
|
44
|
+
spinner.fail('Falta socket.io-client. Ejecuta: npm install socket.io-client');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const socket = ioClient(wsUrl, {
|
|
49
|
+
transports: ['websocket'],
|
|
50
|
+
reconnection: true,
|
|
51
|
+
reconnectionDelay: 2000,
|
|
52
|
+
reconnectionAttempts: 20,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
socket.on('connect', () => {
|
|
56
|
+
socket.emit('open-tunnel', { target: targetUrl }, (res) => {
|
|
57
|
+
if (!res || !res.ok) {
|
|
58
|
+
spinner.fail('No se pudo abrir el túnel: ' + JSON.stringify(res));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const publicUrl = `${baseUrl}/t/${res.tunnelId}`;
|
|
63
|
+
spinner.succeed('Túnel activo');
|
|
64
|
+
console.log('');
|
|
65
|
+
console.log(chalk.bold(' 🚇 URL pública:'), chalk.cyan.underline(publicUrl));
|
|
66
|
+
console.log(chalk.gray(` → ${targetUrl}`));
|
|
67
|
+
console.log('');
|
|
68
|
+
console.log(chalk.gray(' Presiona Ctrl+C para cerrar'));
|
|
69
|
+
console.log('');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Procesar requests del server
|
|
74
|
+
socket.on('tunnel-request', (payload, cb) => {
|
|
75
|
+
const { method, path: reqPath, headers, body, id } = payload;
|
|
76
|
+
|
|
77
|
+
// Construir URL completa al target local
|
|
78
|
+
const localUrl = new URL(reqPath, targetUrl);
|
|
79
|
+
|
|
80
|
+
// Preparar headers — reescribir host al target local
|
|
81
|
+
const fwdHeaders = { ...headers };
|
|
82
|
+
fwdHeaders.host = parsedTarget.host;
|
|
83
|
+
delete fwdHeaders['accept-encoding']; // Evitar compresión para simplificar relay
|
|
84
|
+
|
|
85
|
+
const proto = parsedTarget.protocol === 'https:' ? https : http;
|
|
86
|
+
|
|
87
|
+
const reqOpts = {
|
|
88
|
+
method: method,
|
|
89
|
+
hostname: parsedTarget.hostname,
|
|
90
|
+
port: parsedTarget.port || (parsedTarget.protocol === 'https:' ? 443 : 80),
|
|
91
|
+
path: localUrl.pathname + localUrl.search,
|
|
92
|
+
headers: fwdHeaders,
|
|
93
|
+
rejectUnauthorized: false, // Permitir self-signed certs en localhost
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
97
|
+
process.stdout.write(chalk.gray(` [${timestamp}] `) + chalk.yellow(method.padEnd(6)) + chalk.white(reqPath) + ' ');
|
|
98
|
+
|
|
99
|
+
const localReq = proto.request(reqOpts, (localRes) => {
|
|
100
|
+
const chunks = [];
|
|
101
|
+
localRes.on('data', (chunk) => chunks.push(chunk));
|
|
102
|
+
localRes.on('end', () => {
|
|
103
|
+
const bodyBuf = Buffer.concat(chunks);
|
|
104
|
+
|
|
105
|
+
// Color del status
|
|
106
|
+
const status = localRes.statusCode;
|
|
107
|
+
const statusColor = status < 300 ? chalk.green(status) : status < 400 ? chalk.cyan(status) : chalk.red(status);
|
|
108
|
+
console.log(statusColor + chalk.gray(` (${bodyBuf.length}B)`));
|
|
109
|
+
|
|
110
|
+
cb({
|
|
111
|
+
status: status,
|
|
112
|
+
headers: localRes.headers,
|
|
113
|
+
body: bodyBuf.toString('base64'),
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
localReq.on('error', (err) => {
|
|
119
|
+
console.log(chalk.red('ERR ') + chalk.gray(err.message));
|
|
120
|
+
cb({ error: err.message, status: 502 });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
localReq.setTimeout(25000, () => {
|
|
124
|
+
localReq.destroy();
|
|
125
|
+
console.log(chalk.red('TIMEOUT'));
|
|
126
|
+
cb({ error: 'Local server timeout', status: 504 });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (body) {
|
|
130
|
+
localReq.write(Buffer.from(body, 'base64'));
|
|
131
|
+
}
|
|
132
|
+
localReq.end();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
socket.on('connect_error', (err) => {
|
|
136
|
+
if (spinner.isSpinning) {
|
|
137
|
+
spinner.fail('No se pudo conectar al servidor: ' + err.message);
|
|
138
|
+
} else {
|
|
139
|
+
console.error(chalk.red(' Conexión perdida: ' + err.message));
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
socket.on('disconnect', (reason) => {
|
|
144
|
+
console.log(chalk.yellow('\n Desconectado: ' + reason));
|
|
145
|
+
if (reason === 'io server disconnect') {
|
|
146
|
+
console.log(chalk.gray(' El servidor cerró la conexión.'));
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
console.log(chalk.gray(' Reintentando...'));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Ctrl+C
|
|
153
|
+
process.on('SIGINT', () => {
|
|
154
|
+
console.log(chalk.gray('\n Cerrando túnel...'));
|
|
155
|
+
socket.disconnect();
|
|
156
|
+
process.exit(0);
|
|
157
|
+
});
|
|
158
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "entexto-cli",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.5",
|
|
4
4
|
"description": "CLI oficial de Entexto — Deploy y gestión de proyectos desde tu terminal",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"commander": "^11.0.0",
|
|
30
30
|
"form-data": "^4.0.0",
|
|
31
31
|
"inquirer": "^8.2.6",
|
|
32
|
-
"ora": "^5.4.1"
|
|
32
|
+
"ora": "^5.4.1",
|
|
33
|
+
"socket.io-client": "^4.8.3"
|
|
33
34
|
}
|
|
34
35
|
}
|