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 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) {
@@ -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
- fs.writeFileSync(destFile, content, 'utf8');
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}`));
@@ -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.content]));
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 contenido = contentMap.get(ruta);
186
- if (contenido === undefined) {
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
- fs.writeFileSync(destFile, contenido, 'utf8');
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
- function retryFsOp(opName, ruta, fn, maxRetries, delay) {
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 en VS Code e intenta de nuevo'));
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",
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
  }