entexto-cli 1.1.0 → 1.3.0

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
@@ -29,6 +29,7 @@ program
29
29
  .option('-d, --dir <path>', 'Carpeta a subir (default: carpeta actual)', '.')
30
30
  .option('-w, --watch', 'Modo live: detecta cambios y re-sube automáticamente')
31
31
  .option('-p, --publish', 'Publicar el proyecto después del deploy')
32
+ .option('-f, --full', 'Subir todos los archivos sin comparar hashes (sin delta)')
32
33
  .action(require('../lib/commands/deploy'));
33
34
 
34
35
  // ─── entexto whoami ──────────────────────────────────────────
@@ -67,6 +68,15 @@ const pullAction = require('../lib/commands/pull');
67
68
  .option('-d, --dir <path>', 'Carpeta destino (default: carpeta actual)', '.')
68
69
  .action(pullAction);
69
70
  });
71
+ // ─── entexto sync ────────────────────────────────────────────
72
+ program
73
+ .command('sync')
74
+ .description('Sincronización bidireccional en tiempo real (local ↔ remoto)')
75
+ .option('-i, --id <uuid>', 'UUID del proyecto')
76
+ .option('-d, --dir <path>', 'Carpeta local (default: carpeta actual)', '.')
77
+ .option('-f, --full', 'Subir todos los archivos en el delta inicial (sin comparación)')
78
+ .action(require('../lib/commands/sync'));
79
+
70
80
  program.parse(process.argv);
71
81
 
72
82
  if (!process.argv.slice(2).length) {
@@ -1,203 +1,205 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const inquirer = require('inquirer');
6
- const chalk = require('chalk');
7
- const ora = require('ora');
8
- const FormData = require('form-data');
9
- const chokidar = require('chokidar');
10
- const { getToken } = require('../utils/config');
11
- const { getProjects, deployWithPublish, deployFiles, publishProject } = require('../utils/api');
12
-
13
- // Archivos/carpetas que nunca se suben
14
- const IGNORE_NAMES = new Set([
15
- 'node_modules', '.git', '.env', '.env.local', '.DS_Store',
16
- 'dist', '.next', 'build', 'coverage', '.cache', '.turbo',
17
- 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
18
- '.entexto', 'Thumbs.db',
19
- ]);
20
-
21
- // Extensiones que se excluyen por seguridad
22
- const IGNORE_EXT = new Set([
23
- '.key', '.pem', '.cert', '.p12', '.pfx', '.sqlite', '.sqlite3',
24
- '.db', '.log', '.bak', '.backup',
25
- ]);
26
-
27
- function debeIgnorar(nombre) {
28
- if (IGNORE_NAMES.has(nombre)) return true;
29
- const ext = path.extname(nombre).toLowerCase();
30
- if (IGNORE_EXT.has(ext)) return true;
31
- if (nombre.startsWith('.') && nombre !== '.htaccess') return true;
32
- return false;
33
- }
34
-
35
- function recolectarArchivos(dir, base) {
36
- base = base || dir;
37
- const resultado = [];
38
- let entries;
39
- try {
40
- entries = fs.readdirSync(dir, { withFileTypes: true });
41
- } catch {
42
- return resultado;
43
- }
44
- for (const entry of entries) {
45
- if (debeIgnorar(entry.name)) continue;
46
- const fullPath = path.join(dir, entry.name);
47
- if (entry.isDirectory()) {
48
- resultado.push(...recolectarArchivos(fullPath, base));
49
- } else if (entry.isFile()) {
50
- resultado.push(fullPath);
51
- }
52
- }
53
- return resultado;
54
- }
55
-
56
- async function ejecutarDeploy(uuid, dir, publish) {
57
- const absDir = path.resolve(dir);
58
- const archivos = recolectarArchivos(absDir);
59
-
60
- if (!archivos.length) {
61
- console.log(chalk.yellow(' No se encontraron archivos para subir.'));
62
- return false;
63
- }
64
-
65
- const spinner = ora(`Subiendo ${archivos.length} archivo(s)...`).start();
66
-
67
- try {
68
- const form = new FormData();
69
-
70
- for (const filePath of archivos) {
71
- const ruta = path.relative(absDir, filePath).replace(/\\/g, '/');
72
- form.append(ruta, fs.createReadStream(filePath), { filename: ruta });
73
- }
74
-
75
- let result;
76
- if (publish) {
77
- result = await deployWithPublish(uuid, form);
78
- } else {
79
- result = await deployFiles(uuid, form, false);
80
- }
81
-
82
- spinner.stop();
83
- (result.archivos || []).forEach(r => {
84
- console.log(' ' + chalk.green('✓') + ' ' + chalk.white(r));
85
- });
86
- console.log(
87
- chalk.bold.green(`\n 🚀 Deploy completado — ${result.total} archivo(s)\n`)
88
- );
89
- if (result.publicado && result.publicado.url) {
90
- console.log(' ' + chalk.bold.cyan('🌐 ' + result.publicado.url) + '\n');
91
- }
92
- return true;
93
- } catch (err) {
94
- const msg = err.response?.data?.error || err.message;
95
- spinner.fail(chalk.red('Error en deploy: ' + msg));
96
- return false;
97
- }
98
- }
99
-
100
- module.exports = async function (options) {
101
- if (!getToken()) {
102
- console.log(chalk.red('\n No has iniciado sesión. Ejecuta: entexto login\n'));
103
- process.exit(1);
104
- }
105
-
106
- // ── Resolver proyecto destino ─────────────────────────────
107
- let uuid = options.id;
108
-
109
- if (!uuid) {
110
- const spinner = ora('Cargando proyectos...').start();
111
- let projects;
112
- try {
113
- projects = await getProjects();
114
- spinner.stop();
115
- } catch (err) {
116
- const msg = err.response?.data?.error || err.message;
117
- spinner.fail(chalk.red('Error cargando proyectos: ' + msg));
118
- process.exit(1);
119
- }
120
-
121
- if (!projects.length) {
122
- console.log(chalk.yellow('\n No tienes proyectos. Crea uno primero en entexto.com\n'));
123
- process.exit(0);
124
- }
125
-
126
- const { selectedUuid } = await inquirer.prompt([{
127
- type: 'list',
128
- name: 'selectedUuid',
129
- message: '¿A qué proyecto deseas subir los archivos?',
130
- pageSize: 10,
131
- choices: projects.map(p => ({
132
- name: [
133
- (p.project_uuid || String(p.id)).padEnd(20),
134
- (p.name || 'Sin nombre').slice(0, 28).padEnd(30),
135
- p.is_published ? chalk.green('📡') : chalk.yellow('🔒'),
136
- ].join(' '),
137
- value: p.project_uuid || String(p.id),
138
- })),
139
- }]);
140
-
141
- uuid = selectedUuid;
142
- }
143
-
144
- // ── Carpeta a subir ──────────────────────────────────────
145
- const dir = options.dir || '.';
146
- if (!fs.existsSync(path.resolve(dir))) {
147
- console.log(chalk.red(` La carpeta "${dir}" no existe.\n`));
148
- process.exit(1);
149
- }
150
-
151
- // ── Preguntar publicar (solo si no está en modo watch ni --publish) ──
152
- let publish = !!options.publish;
153
- if (!publish && !options.watch) {
154
- const { shouldPublish } = await inquirer.prompt([{
155
- type: 'confirm',
156
- name: 'shouldPublish',
157
- message: '¿Publicar el proyecto después del deploy?',
158
- default: true,
159
- }]);
160
- publish = shouldPublish;
161
- }
162
-
163
- // ── Modo normal ─────────────────────────────────────────
164
- if (!options.watch) {
165
- await ejecutarDeploy(uuid, dir, publish);
166
- return;
167
- }
168
-
169
- // ── Modo --watch ─────────────────────────────────────────
170
- console.log(chalk.bold.cyan('\n 👀 Modo watch activo. Ctrl+C para detener.\n'));
171
- await ejecutarDeploy(uuid, dir, publish);
172
-
173
- let debounce = null;
174
- const changed = new Set();
175
-
176
- const watcher = chokidar.watch(path.resolve(dir), {
177
- ignored: (filePath) => {
178
- const name = path.basename(filePath);
179
- return debeIgnorar(name);
180
- },
181
- ignoreInitial: true,
182
- usePolling: false,
183
- awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
184
- });
185
-
186
- watcher.on('all', (event, filePath) => {
187
- changed.add(filePath);
188
- clearTimeout(debounce);
189
- debounce = setTimeout(async () => {
190
- const files = [...changed];
191
- changed.clear();
192
- const nombres = files
193
- .map(f => path.relative(path.resolve(dir), f))
194
- .join(', ');
195
- console.log(chalk.gray(` [watch] ${event}: ${nombres}`));
196
- await ejecutarDeploy(uuid, dir, false);
197
- }, 300);
198
- });
199
-
200
- watcher.on('error', err => {
201
- console.error(chalk.red(' [watch] Error: ' + err.message));
202
- });
203
- };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const inquirer = require('inquirer');
6
+ const chalk = require('chalk');
7
+ const ora = require('ora');
8
+ const FormData = require('form-data');
9
+ const chokidar = require('chokidar');
10
+ const { getToken } = require('../utils/config');
11
+ const { getProjects, deployWithPublish, deployFiles, publishProject, getManifest } = require('../utils/api');
12
+ const { hashFile } = require('../utils/delta');
13
+
14
+ // Archivos/carpetas que nunca se suben
15
+ const IGNORE_NAMES = new Set([
16
+ 'node_modules', '.git', '.env', '.env.local', '.DS_Store',
17
+ 'dist', '.next', 'build', 'coverage', '.cache', '.turbo',
18
+ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
19
+ '.entexto', 'Thumbs.db',
20
+ ]);
21
+
22
+ // Extensiones que se excluyen por seguridad
23
+ const IGNORE_EXT = new Set([
24
+ '.key', '.pem', '.cert', '.p12', '.pfx', '.sqlite', '.sqlite3',
25
+ '.db', '.log', '.bak', '.backup',
26
+ ]);
27
+
28
+ function debeIgnorar(nombre) {
29
+ if (IGNORE_NAMES.has(nombre)) return true;
30
+ const ext = path.extname(nombre).toLowerCase();
31
+ if (IGNORE_EXT.has(ext)) return true;
32
+ if (nombre.startsWith('.') && nombre !== '.htaccess') return true;
33
+ return false;
34
+ }
35
+
36
+ function recolectarArchivos(dir) {
37
+ const resultado = [];
38
+ let entries;
39
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return resultado; }
40
+ for (const entry of entries) {
41
+ if (debeIgnorar(entry.name)) continue;
42
+ const fullPath = path.join(dir, entry.name);
43
+ if (entry.isDirectory()) resultado.push(...recolectarArchivos(fullPath));
44
+ else if (entry.isFile()) resultado.push(fullPath);
45
+ }
46
+ return resultado;
47
+ }
48
+
49
+ /** Sube UN archivo (para watch mode) */
50
+ async function subirArchivoSolo(uuid, absDir, filePath, publish) {
51
+ if (!fs.existsSync(filePath)) return;
52
+ const ruta = path.relative(absDir, filePath).replace(/\\/g, '/');
53
+ if (debeIgnorar(path.basename(filePath))) return;
54
+ try {
55
+ const form = new FormData();
56
+ form.append(ruta, fs.createReadStream(filePath), { filename: ruta });
57
+ if (publish) {
58
+ await deployWithPublish(uuid, form);
59
+ } else {
60
+ await deployFiles(uuid, form, false);
61
+ }
62
+ console.log(' ' + chalk.green('') + ' ' + chalk.white(ruta));
63
+ } catch (err) {
64
+ const msg = err.response?.data?.error || err.message;
65
+ console.log(' ' + chalk.red('') + ' ' + chalk.white(ruta) + chalk.red(' ' + msg));
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Deploy principal.
71
+ * @param {string} uuid
72
+ * @param {string} dir
73
+ * @param {boolean} publish
74
+ * @param {boolean} full true = subir todo, false = solo cambios (delta)
75
+ */
76
+ async function ejecutarDeploy(uuid, dir, publish, full) {
77
+ const absDir = path.resolve(dir);
78
+ const archivos = recolectarArchivos(absDir);
79
+
80
+ if (!archivos.length) {
81
+ console.log(chalk.yellow(' No se encontraron archivos para subir.'));
82
+ return false;
83
+ }
84
+
85
+ // Delta: obtener manifest del servidor
86
+ let manifest = {};
87
+ if (!full) {
88
+ try { manifest = await getManifest(uuid); } catch {}
89
+ }
90
+
91
+ const aSubir = [];
92
+ const omitidos = [];
93
+
94
+ for (const filePath of archivos) {
95
+ const ruta = path.relative(absDir, filePath).replace(/\\/g, '/');
96
+ if (!full && manifest[ruta]) {
97
+ const localHash = hashFile(filePath);
98
+ if (localHash && manifest[ruta] === localHash) { omitidos.push(ruta); continue; }
99
+ }
100
+ aSubir.push({ filePath, ruta });
101
+ }
102
+
103
+ omitidos.forEach(ruta => console.log(' ' + chalk.gray('') + ' ' + chalk.gray(ruta)));
104
+
105
+ if (!aSubir.length) {
106
+ console.log(chalk.green.bold('\n Todo al día ningún archivo cambió.\n'));
107
+ if (publish) {
108
+ const sp2 = ora('Publicando...').start();
109
+ try {
110
+ const r = await publishProject(uuid);
111
+ sp2.succeed('Publicado: ' + chalk.cyan(r.url || ''));
112
+ } catch (e) { sp2.fail('Error: ' + (e.response?.data?.error || e.message)); }
113
+ }
114
+ return true;
115
+ }
116
+
117
+ const spinner = ora(`Subiendo ${aSubir.length} archivo(s)...`).start();
118
+
119
+ try {
120
+ const form = new FormData();
121
+ for (const { filePath, ruta } of aSubir) {
122
+ form.append(ruta, fs.createReadStream(filePath), { filename: ruta });
123
+ }
124
+ const result = publish
125
+ ? await deployWithPublish(uuid, form)
126
+ : await deployFiles(uuid, form, false);
127
+
128
+ spinner.stop();
129
+ (result.archivos || []).forEach(r => console.log(' ' + chalk.green('') + ' ' + chalk.white(r)));
130
+ console.log(
131
+ chalk.bold.green(`\n Deploy ${result.total || aSubir.length} subido(s)`)
132
+ + (omitidos.length ? chalk.gray(`, ${omitidos.length} sin cambios`) : '') + '\n'
133
+ );
134
+ if (result.publicado?.url) console.log(' ' + chalk.bold.cyan(' ' + result.publicado.url) + '\n');
135
+ return true;
136
+ } catch (err) {
137
+ const msg = err.response?.data?.error || err.message;
138
+ spinner.fail(chalk.red('Error en deploy: ' + msg));
139
+ return false;
140
+ }
141
+ }
142
+
143
+ module.exports = async function (options) {
144
+ if (!getToken()) { console.log(chalk.red('\n No has iniciado sesión. Ejecuta: entexto login\n')); process.exit(1); }
145
+
146
+ const full = !!options.full;
147
+ if (full) console.log(chalk.gray('\n Modo completo (--full): subiendo todos los archivos...\n'));
148
+
149
+ let uuid = options.id;
150
+ if (!uuid) {
151
+ const spinner = ora('Cargando proyectos...').start();
152
+ let projects;
153
+ try { projects = await getProjects(); spinner.stop(); }
154
+ catch (err) { spinner.fail(chalk.red('Error: ' + (err.response?.data?.error || err.message))); process.exit(1); }
155
+
156
+ if (!projects.length) { console.log(chalk.yellow('\n No tienes proyectos.\n')); process.exit(0); }
157
+
158
+ const { selectedUuid } = await inquirer.prompt([{
159
+ type: 'list', name: 'selectedUuid', message: '¿A qué proyecto deseas subir los archivos?', pageSize: 10,
160
+ choices: projects.map(p => ({
161
+ name: [(p.project_uuid || String(p.id)).padEnd(20), (p.name || 'Sin nombre').slice(0,28).padEnd(30), p.is_published ? chalk.green('') : chalk.yellow('')].join(' '),
162
+ value: p.project_uuid || String(p.id),
163
+ })),
164
+ }]);
165
+ uuid = selectedUuid;
166
+ }
167
+
168
+ const dir = options.dir || '.';
169
+ if (!fs.existsSync(path.resolve(dir))) { console.log(chalk.red(` La carpeta "${dir}" no existe.\n`)); process.exit(1); }
170
+
171
+ let publish = !!options.publish;
172
+ if (!publish && !options.watch) {
173
+ const { shouldPublish } = await inquirer.prompt([{ type: 'confirm', name: 'shouldPublish', message: '¿Publicar el proyecto después del deploy?', default: true }]);
174
+ publish = shouldPublish;
175
+ }
176
+
177
+ if (!options.watch) { await ejecutarDeploy(uuid, dir, publish, full); return; }
178
+
179
+ console.log(chalk.bold.cyan('\n Modo watch activo. Ctrl+C para detener.\n'));
180
+ await ejecutarDeploy(uuid, dir, publish, full);
181
+
182
+ let debounce = null;
183
+ const changed = new Set();
184
+ const absDir = path.resolve(dir);
185
+
186
+ const watcher = chokidar.watch(absDir, {
187
+ ignored: (fp) => debeIgnorar(path.basename(fp)),
188
+ ignoreInitial: true, usePolling: false,
189
+ awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
190
+ });
191
+
192
+ function disparar() {
193
+ clearTimeout(debounce);
194
+ debounce = setTimeout(async () => {
195
+ const files = [...changed]; changed.clear();
196
+ for (const fp of files) await subirArchivoSolo(uuid, absDir, fp, false);
197
+ }, 300);
198
+ }
199
+
200
+ watcher.on('add', fp => { changed.add(fp); disparar(); });
201
+ watcher.on('change', fp => { changed.add(fp); disparar(); });
202
+ watcher.on('error', err => console.error(chalk.red(' [watch] Error: ' + err.message)));
203
+ };
204
+
205
+ module.exports.subirArchivoSolo = subirArchivoSolo;
@@ -0,0 +1,314 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const inquirer = require('inquirer');
6
+ const chalk = require('chalk');
7
+ const ora = require('ora');
8
+ const FormData = require('form-data');
9
+ const chokidar = require('chokidar');
10
+ const { getToken } = require('../utils/config');
11
+ const { getProjects, deployFiles, getManifest, pullProject, streamEvents } = require('../utils/api');
12
+ const { hashFile, hashContent } = require('../utils/delta');
13
+
14
+ const IGNORE_NAMES = new Set([
15
+ 'node_modules', '.git', '.env', '.env.local', '.DS_Store',
16
+ 'dist', '.next', 'build', 'coverage', '.cache', '.turbo',
17
+ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
18
+ '.entexto', 'Thumbs.db',
19
+ ]);
20
+ const IGNORE_EXT = new Set([
21
+ '.key', '.pem', '.cert', '.p12', '.pfx', '.sqlite', '.sqlite3',
22
+ '.db', '.log', '.bak', '.backup',
23
+ ]);
24
+ function debeIgnorar(nombre) {
25
+ if (IGNORE_NAMES.has(nombre)) return true;
26
+ const ext = path.extname(nombre).toLowerCase();
27
+ if (IGNORE_EXT.has(ext)) return true;
28
+ if (nombre.startsWith('.') && nombre !== '.htaccess') return true;
29
+ return false;
30
+ }
31
+ function recolectarArchivos(dir) {
32
+ const resultado = [];
33
+ let entries;
34
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return resultado; }
35
+ for (const entry of entries) {
36
+ if (debeIgnorar(entry.name)) continue;
37
+ const full = path.join(dir, entry.name);
38
+ if (entry.isDirectory()) resultado.push(...recolectarArchivos(full));
39
+ else if (entry.isFile()) resultado.push(full);
40
+ }
41
+ return resultado;
42
+ }
43
+
44
+ // Rastrea cuándo subimos cada archivo (para evitar eco del SSE)
45
+ const localUploadedAt = new Map();
46
+
47
+ async function subirArchivoSolo(uuid, absDir, filePath) {
48
+ if (!fs.existsSync(filePath)) return;
49
+ const ruta = path.relative(absDir, filePath).replace(/\\/g, '/');
50
+ if (debeIgnorar(path.basename(filePath))) return;
51
+ try {
52
+ const form = new FormData();
53
+ form.append(ruta, fs.createReadStream(filePath), { filename: ruta });
54
+ await deployFiles(uuid, form, false);
55
+ localUploadedAt.set(ruta, Date.now());
56
+ console.log(' ' + chalk.green('↑') + ' ' + chalk.white(ruta) + chalk.gray(' → remoto'));
57
+ } catch (err) {
58
+ const msg = err.response?.data?.error || err.message;
59
+ console.log(' ' + chalk.red('✖') + ' ' + chalk.white(ruta) + chalk.red(' ' + msg));
60
+ }
61
+ }
62
+
63
+ module.exports = async function sync(options) {
64
+ if (!getToken()) {
65
+ console.error(chalk.red('✖ No has iniciado sesión. Ejecuta: entexto login'));
66
+ process.exit(1);
67
+ }
68
+
69
+ let uuid = options.id || null;
70
+
71
+ if (!uuid) {
72
+ const spinner = ora('Cargando proyectos...').start();
73
+ let proyectos;
74
+ try {
75
+ proyectos = await getProjects();
76
+ spinner.stop();
77
+ } catch (err) {
78
+ spinner.stop();
79
+ console.error(chalk.red('✖ Error: ' + err.message));
80
+ process.exit(1);
81
+ }
82
+ if (!proyectos || !proyectos.length) {
83
+ console.log(chalk.yellow('No tienes proyectos.'));
84
+ return;
85
+ }
86
+ const choices = proyectos.map((p, i) => ({
87
+ name: `${String(i + 1).padStart(2, ' ')}. ${p.name}${p.is_published ? chalk.green(' [publicado]') : ''} ${chalk.gray(p.project_uuid)}`,
88
+ value: p.project_uuid,
89
+ short: p.name,
90
+ }));
91
+ const { selectedUuid } = await inquirer.prompt([{
92
+ type: 'list', name: 'selectedUuid',
93
+ message: 'Elige el proyecto a sincronizar:',
94
+ choices, pageSize: 15,
95
+ }]);
96
+ uuid = selectedUuid;
97
+ }
98
+
99
+ const dir = options.dir || '.';
100
+ const absDir = path.resolve(dir);
101
+ if (!fs.existsSync(absDir)) {
102
+ console.error(chalk.red(`✖ La carpeta "${dir}" no existe.`));
103
+ process.exit(1);
104
+ }
105
+
106
+ // ── 1. Delta inicial bidireccional ──────────────────────────────────────
107
+ console.log(chalk.bold('\n🔄 Sincronización inicial...\n'));
108
+
109
+ // 1a. Manifest remoto { ruta: hash }
110
+ let manifest = {};
111
+ try {
112
+ manifest = await getManifest(uuid);
113
+ } catch {
114
+ console.log(chalk.gray(' (manifest no disponible, comparando todos)'));
115
+ }
116
+
117
+ // 1b. Hashes de los archivos locales
118
+ const todosArchivos = recolectarArchivos(absDir);
119
+ const localHashes = new Map();
120
+ for (const fp of todosArchivos) {
121
+ const ruta = path.relative(absDir, fp).replace(/\\/g, '/');
122
+ localHashes.set(ruta, hashFile(fp));
123
+ }
124
+
125
+ // 1c. Clasificar qué hacer con cada archivo
126
+ const aDescargar = []; // ruta[] — remoto → local
127
+ const aSubir = []; // { filePath, ruta }[] — local → remoto
128
+
129
+ // Recorrer archivos remotos
130
+ for (const [ruta, remHash] of Object.entries(manifest)) {
131
+ const localHash = localHashes.get(ruta);
132
+ if (!localHash) {
133
+ // No existe localmente → descargar
134
+ aDescargar.push(ruta);
135
+ } else if (localHash === remHash) {
136
+ // Mismo contenido → sin cambios
137
+ console.log(' ' + chalk.gray('──') + ' ' + chalk.gray(ruta));
138
+ } else {
139
+ // Hash diferente → si el archivo local fue modificado hace < 10s, local gana
140
+ // de lo contrario remoto gana (remoto es fuente de verdad en sync inicial)
141
+ try {
142
+ const stat = fs.statSync(path.join(absDir, ruta));
143
+ if (Date.now() - stat.mtimeMs < 10000) {
144
+ aSubir.push({ filePath: path.join(absDir, ruta), ruta });
145
+ } else {
146
+ aDescargar.push(ruta);
147
+ }
148
+ } catch {
149
+ aDescargar.push(ruta);
150
+ }
151
+ }
152
+ }
153
+
154
+ // Archivos solo en local (no están en remoto) → subir
155
+ for (const [ruta] of localHashes) {
156
+ if (!(ruta in manifest)) {
157
+ aSubir.push({ filePath: path.join(absDir, ruta), ruta });
158
+ }
159
+ }
160
+
161
+ // 1d. Descargar archivos remotos faltantes o actualizados
162
+ if (aDescargar.length > 0) {
163
+ const sp = ora(`Descargando ${aDescargar.length} archivo(s) del servidor...`).start();
164
+ try {
165
+ const pullData = await pullProject(uuid);
166
+ const archivos = pullData.archivos || [];
167
+ const contentMap = new Map(archivos.map(a => [a.ruta, a.content]));
168
+ sp.stop();
169
+ let downloaded = 0;
170
+ for (const ruta of aDescargar) {
171
+ const contenido = contentMap.get(ruta);
172
+ if (contenido === undefined) {
173
+ console.log(' ' + chalk.yellow('⚠') + ' No encontrado en pull: ' + chalk.gray(ruta));
174
+ continue;
175
+ }
176
+ const destFile = path.join(absDir, ruta);
177
+ const destDir = path.dirname(destFile);
178
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
179
+ fs.writeFileSync(destFile, contenido, 'utf8');
180
+ localUploadedAt.set(ruta, Date.now()); // evitar re-subida por chokidar
181
+ console.log(' ' + chalk.cyan('↓') + ' ' + chalk.white(ruta) + chalk.gray(' ← remoto'));
182
+ downloaded++;
183
+ }
184
+ console.log(chalk.green(` ✔ ${downloaded} archivo(s) descargados\n`));
185
+ } catch (err) {
186
+ sp.fail('Error descargando: ' + (err.response?.data?.error || err.message));
187
+ }
188
+ }
189
+
190
+ // 1e. Subir archivos locales nuevos o modificados recientemente
191
+ if (aSubir.length > 0) {
192
+ const sp = ora(`Subiendo ${aSubir.length} archivo(s) al servidor...`).start();
193
+ try {
194
+ const form = new FormData();
195
+ for (const { filePath, ruta } of aSubir) {
196
+ form.append(ruta, fs.createReadStream(filePath), { filename: ruta });
197
+ localUploadedAt.set(ruta, Date.now());
198
+ }
199
+ await deployFiles(uuid, form, false);
200
+ sp.stop();
201
+ aSubir.forEach(({ ruta }) =>
202
+ console.log(' ' + chalk.green('↑') + ' ' + chalk.white(ruta) + chalk.gray(' → remoto'))
203
+ );
204
+ console.log(chalk.green(` ✔ ${aSubir.length} archivo(s) subidos\n`));
205
+ } catch (err) {
206
+ sp.fail('Error subiendo: ' + (err.response?.data?.error || err.message));
207
+ }
208
+ }
209
+
210
+ if (aDescargar.length === 0 && aSubir.length === 0) {
211
+ console.log(chalk.green(' ✔ Todo sincronizado, sin cambios.\n'));
212
+ }
213
+
214
+ // ── 2. Canal SSE (remoto → local) ───────────────────────────────────────
215
+ console.log(chalk.bold.cyan('\n🔗 Abriendo canal bidireccional...\n'));
216
+
217
+ let sseStream = null;
218
+ let sseReconnectTimer = null;
219
+
220
+ function manejarEventoRemoto(eventType, data) {
221
+ if (eventType !== 'file-update') return;
222
+ const { ruta, contenido } = data || {};
223
+ if (!ruta || contenido === undefined) return;
224
+
225
+ // Evitar eco: si acabamos de subir este archivo (< 3s), ignorar
226
+ const uploadedAt = localUploadedAt.get(ruta);
227
+ if (uploadedAt && Date.now() - uploadedAt < 3000) return;
228
+
229
+ const destFile = path.join(absDir, ruta);
230
+ const destDir = path.dirname(destFile);
231
+
232
+ // Detección de conflicto: archivo local modificado hace < 5s Y diferente al remoto
233
+ try {
234
+ if (fs.existsSync(destFile)) {
235
+ const stat = fs.statSync(destFile);
236
+ const localHash = hashFile(destFile);
237
+ const remHash = hashContent(contenido);
238
+ if (localHash !== remHash && Date.now() - stat.mtimeMs < 5000) {
239
+ const conflictPath = destFile + '.conflict.' + Date.now();
240
+ fs.writeFileSync(conflictPath, contenido, 'utf8');
241
+ console.log(' ' + chalk.yellow('⚠') + ' CONFLICTO ' + chalk.white(ruta));
242
+ console.log(' Versión remota guardada → ' + chalk.gray(path.basename(conflictPath)));
243
+ return;
244
+ }
245
+ }
246
+ } catch {}
247
+
248
+ try {
249
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
250
+ fs.writeFileSync(destFile, contenido, 'utf8');
251
+ localUploadedAt.set(ruta, Date.now()); // evitar re-subida por chokidar
252
+ console.log(' ' + chalk.cyan('↓') + ' ' + chalk.white(ruta) + chalk.gray(' ← remoto'));
253
+ } catch {
254
+ console.log(' ' + chalk.red('✖') + ' No se pudo escribir: ' + ruta);
255
+ }
256
+ }
257
+
258
+ function conectarSSE() {
259
+ streamEvents(uuid, manejarEventoRemoto)
260
+ .then(stream => {
261
+ sseStream = stream;
262
+ console.log(chalk.green(' ✔ SSE conectado — escuchando cambios remotos\n'));
263
+ stream.on('error', () => {
264
+ console.log(chalk.yellow('\n ⚠ SSE desconectado, reconectando en 5s...'));
265
+ sseReconnectTimer = setTimeout(conectarSSE, 5000);
266
+ });
267
+ stream.on('close', () => {
268
+ console.log(chalk.yellow('\n ⚠ SSE cerrado, reconectando en 5s...'));
269
+ sseReconnectTimer = setTimeout(conectarSSE, 5000);
270
+ });
271
+ })
272
+ .catch(err => {
273
+ console.log(chalk.yellow(' ⚠ SSE no disponible: ' + err.message));
274
+ sseReconnectTimer = setTimeout(conectarSSE, 5000);
275
+ });
276
+ }
277
+
278
+ conectarSSE();
279
+
280
+ // ── 3. Watch local → remoto ─────────────────────────────────────────────
281
+ console.log(chalk.bold('👀 Watching: ' + absDir));
282
+ console.log(chalk.gray(' Ctrl+C para detener\n'));
283
+
284
+ let debounce = null;
285
+ const changed = new Set();
286
+
287
+ const watcher = chokidar.watch(absDir, {
288
+ ignored: (fp) => debeIgnorar(path.basename(fp)),
289
+ ignoreInitial: true,
290
+ usePolling: false,
291
+ awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
292
+ });
293
+
294
+ function disparar() {
295
+ clearTimeout(debounce);
296
+ debounce = setTimeout(async () => {
297
+ const files = [...changed];
298
+ changed.clear();
299
+ for (const fp of files) await subirArchivoSolo(uuid, absDir, fp);
300
+ }, 300);
301
+ }
302
+
303
+ watcher.on('add', fp => { changed.add(fp); disparar(); });
304
+ watcher.on('change', fp => { changed.add(fp); disparar(); });
305
+ watcher.on('error', err => console.error(chalk.red(' [watch] Error: ' + err.message)));
306
+
307
+ process.on('SIGINT', () => {
308
+ clearTimeout(sseReconnectTimer);
309
+ if (sseStream) try { sseStream.destroy(); } catch {}
310
+ watcher.close();
311
+ console.log(chalk.gray('\n Sync detenido.\n'));
312
+ process.exit(0);
313
+ });
314
+ };
package/lib/utils/api.js CHANGED
@@ -64,4 +64,36 @@ async function pullProject(uuid) {
64
64
  return res.data;
65
65
  }
66
66
 
67
- module.exports = { login, getProjects, deployFiles, deployWithPublish, publishProject, pullProject };
67
+ async function getManifest(uuid) {
68
+ const res = await client().get(`/api/cli/manifest/${uuid}`);
69
+ return res.data.manifest || {};
70
+ }
71
+
72
+ async function streamEvents(uuid, onEvent) {
73
+ const token = getToken();
74
+ const baseURL = getBaseUrl();
75
+ const response = await axios.get(`${baseURL}/api/cli/stream/${uuid}`, {
76
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
77
+ responseType: 'stream',
78
+ timeout: 0,
79
+ });
80
+ const stream = response.data;
81
+ let buffer = '';
82
+ stream.on('data', (chunk) => {
83
+ buffer += chunk.toString();
84
+ const parts = buffer.split('\n');
85
+ buffer = parts.pop();
86
+ let eventType = 'message', dataStr = '';
87
+ for (const line of parts) {
88
+ if (line.startsWith('event: ')) eventType = line.slice(7).trim();
89
+ else if (line.startsWith('data: ')) dataStr = line.slice(6).trim();
90
+ else if (line === '' && dataStr) {
91
+ try { onEvent(eventType, JSON.parse(dataStr)); } catch {}
92
+ eventType = 'message'; dataStr = '';
93
+ }
94
+ }
95
+ });
96
+ return stream;
97
+ }
98
+
99
+ module.exports = { login, getProjects, deployFiles, deployWithPublish, publishProject, pullProject, getManifest, streamEvents };
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+
6
+ /**
7
+ * SHA256 (16 chars) del contenido de un Buffer o string.
8
+ */
9
+ function hashContent(content) {
10
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
11
+ }
12
+
13
+ /**
14
+ * SHA256 (16 chars) del contenido de un archivo en disco.
15
+ * Devuelve null si no se puede leer.
16
+ */
17
+ function hashFile(filePath) {
18
+ try {
19
+ const buf = fs.readFileSync(filePath);
20
+ return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 16);
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ module.exports = { hashContent, hashFile };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entexto-cli",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
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": {