entexto-cli 1.2.0 → 1.4.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.
@@ -8,7 +8,7 @@ const ora = require('ora');
8
8
  const FormData = require('form-data');
9
9
  const chokidar = require('chokidar');
10
10
  const { getToken } = require('../utils/config');
11
- const { getProjects, deployFiles, getManifest, streamEvents } = require('../utils/api');
11
+ const { getProjects, deployFiles, getManifest, pullProject, streamEvents, deleteFileRemote } = require('../utils/api');
12
12
  const { hashFile, hashContent } = require('../utils/delta');
13
13
 
14
14
  const IGNORE_NAMES = new Set([
@@ -43,6 +43,7 @@ function recolectarArchivos(dir) {
43
43
 
44
44
  // Rastrea cuándo subimos cada archivo (para evitar eco del SSE)
45
45
  const localUploadedAt = new Map();
46
+ const localDeletedAt = new Map();
46
47
 
47
48
  async function subirArchivoSolo(uuid, absDir, filePath) {
48
49
  if (!fs.existsSync(filePath)) return;
@@ -60,6 +61,19 @@ async function subirArchivoSolo(uuid, absDir, filePath) {
60
61
  }
61
62
  }
62
63
 
64
+ async function eliminarArchivoRemoto(uuid, absDir, filePath) {
65
+ const ruta = path.relative(absDir, filePath).replace(/\\/g, '/');
66
+ if (debeIgnorar(path.basename(filePath))) return;
67
+ try {
68
+ await deleteFileRemote(uuid, ruta);
69
+ localDeletedAt.set(ruta, Date.now());
70
+ console.log(' ' + chalk.red('✖') + ' ' + chalk.white(ruta) + chalk.gray(' borrado → remoto'));
71
+ } catch (err) {
72
+ const msg = err.response?.data?.error || err.message;
73
+ console.log(' ' + chalk.red('✖') + ' ' + chalk.white(ruta) + chalk.red(' error borrando: ' + msg));
74
+ }
75
+ }
76
+
63
77
  module.exports = async function sync(options) {
64
78
  if (!getToken()) {
65
79
  console.error(chalk.red('✖ No has iniciado sesión. Ejecuta: entexto login'));
@@ -103,9 +117,10 @@ module.exports = async function sync(options) {
103
117
  process.exit(1);
104
118
  }
105
119
 
106
- // ── 1. Delta inicial: subir solo cambios locales → remoto ───────────────
107
- console.log(chalk.bold('\n🔄 Delta inicial...\n'));
108
- const todosArchivos = recolectarArchivos(absDir);
120
+ // ── 1. Delta inicial bidireccional ──────────────────────────────────────
121
+ console.log(chalk.bold('\n🔄 Sincronización inicial...\n'));
122
+
123
+ // 1a. Manifest remoto { ruta: hash }
109
124
  let manifest = {};
110
125
  try {
111
126
  manifest = await getManifest(uuid);
@@ -113,19 +128,82 @@ module.exports = async function sync(options) {
113
128
  console.log(chalk.gray(' (manifest no disponible, comparando todos)'));
114
129
  }
115
130
 
116
- const aSubir = [];
117
- for (const filePath of todosArchivos) {
118
- const ruta = path.relative(absDir, filePath).replace(/\\/g, '/');
119
- const localHash = hashFile(filePath);
120
- if (manifest[ruta] && manifest[ruta] === localHash) {
131
+ // 1b. Hashes de los archivos locales
132
+ const todosArchivos = recolectarArchivos(absDir);
133
+ const localHashes = new Map();
134
+ for (const fp of todosArchivos) {
135
+ const ruta = path.relative(absDir, fp).replace(/\\/g, '/');
136
+ localHashes.set(ruta, hashFile(fp));
137
+ }
138
+
139
+ // 1c. Clasificar qué hacer con cada archivo
140
+ const aDescargar = []; // ruta[] — remoto → local
141
+ const aSubir = []; // { filePath, ruta }[] — local → remoto
142
+
143
+ // Recorrer archivos remotos
144
+ for (const [ruta, remHash] of Object.entries(manifest)) {
145
+ const localHash = localHashes.get(ruta);
146
+ if (!localHash) {
147
+ // No existe localmente → descargar
148
+ aDescargar.push(ruta);
149
+ } else if (localHash === remHash) {
150
+ // Mismo contenido → sin cambios
121
151
  console.log(' ' + chalk.gray('──') + ' ' + chalk.gray(ruta));
122
152
  } else {
123
- aSubir.push({ filePath, ruta });
153
+ // Hash diferente → si el archivo local fue modificado hace < 10s, local gana
154
+ // de lo contrario remoto gana (remoto es fuente de verdad en sync inicial)
155
+ try {
156
+ const stat = fs.statSync(path.join(absDir, ruta));
157
+ if (Date.now() - stat.mtimeMs < 10000) {
158
+ aSubir.push({ filePath: path.join(absDir, ruta), ruta });
159
+ } else {
160
+ aDescargar.push(ruta);
161
+ }
162
+ } catch {
163
+ aDescargar.push(ruta);
164
+ }
165
+ }
166
+ }
167
+
168
+ // Archivos solo en local (no están en remoto) → subir
169
+ for (const [ruta] of localHashes) {
170
+ if (!(ruta in manifest)) {
171
+ aSubir.push({ filePath: path.join(absDir, ruta), ruta });
124
172
  }
125
173
  }
126
174
 
175
+ // 1d. Descargar archivos remotos faltantes o actualizados
176
+ if (aDescargar.length > 0) {
177
+ const sp = ora(`Descargando ${aDescargar.length} archivo(s) del servidor...`).start();
178
+ try {
179
+ const pullData = await pullProject(uuid);
180
+ const archivos = pullData.archivos || [];
181
+ const contentMap = new Map(archivos.map(a => [a.ruta, a.content]));
182
+ sp.stop();
183
+ let downloaded = 0;
184
+ for (const ruta of aDescargar) {
185
+ const contenido = contentMap.get(ruta);
186
+ if (contenido === undefined) {
187
+ console.log(' ' + chalk.yellow('⚠') + ' No encontrado en pull: ' + chalk.gray(ruta));
188
+ continue;
189
+ }
190
+ const destFile = path.join(absDir, ruta);
191
+ const destDir = path.dirname(destFile);
192
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
193
+ fs.writeFileSync(destFile, contenido, 'utf8');
194
+ localUploadedAt.set(ruta, Date.now()); // evitar re-subida por chokidar
195
+ console.log(' ' + chalk.cyan('↓') + ' ' + chalk.white(ruta) + chalk.gray(' ← remoto'));
196
+ downloaded++;
197
+ }
198
+ console.log(chalk.green(` ✔ ${downloaded} archivo(s) descargados\n`));
199
+ } catch (err) {
200
+ sp.fail('Error descargando: ' + (err.response?.data?.error || err.message));
201
+ }
202
+ }
203
+
204
+ // 1e. Subir archivos locales nuevos o modificados recientemente
127
205
  if (aSubir.length > 0) {
128
- const sp = ora(`Subiendo ${aSubir.length} archivo(s) modificados...`).start();
206
+ const sp = ora(`Subiendo ${aSubir.length} archivo(s) al servidor...`).start();
129
207
  try {
130
208
  const form = new FormData();
131
209
  for (const { filePath, ruta } of aSubir) {
@@ -133,13 +211,18 @@ module.exports = async function sync(options) {
133
211
  localUploadedAt.set(ruta, Date.now());
134
212
  }
135
213
  await deployFiles(uuid, form, false);
136
- sp.succeed(`${aSubir.length} archivo(s) subidos`);
137
- aSubir.forEach(({ ruta }) => console.log(' ' + chalk.green('↑') + ' ' + chalk.white(ruta)));
214
+ sp.stop();
215
+ aSubir.forEach(({ ruta }) =>
216
+ console.log(' ' + chalk.green('↑') + ' ' + chalk.white(ruta) + chalk.gray(' → remoto'))
217
+ );
218
+ console.log(chalk.green(` ✔ ${aSubir.length} archivo(s) subidos\n`));
138
219
  } catch (err) {
139
- sp.fail('Error en delta inicial: ' + (err.response?.data?.error || err.message));
220
+ sp.fail('Error subiendo: ' + (err.response?.data?.error || err.message));
140
221
  }
141
- } else {
142
- console.log(chalk.green(' ✔ Todo sincronizado, sin cambios locales.\n'));
222
+ }
223
+
224
+ if (aDescargar.length === 0 && aSubir.length === 0) {
225
+ console.log(chalk.green(' ✔ Todo sincronizado, sin cambios.\n'));
143
226
  }
144
227
 
145
228
  // ── 2. Canal SSE (remoto → local) ───────────────────────────────────────
@@ -149,6 +232,36 @@ module.exports = async function sync(options) {
149
232
  let sseReconnectTimer = null;
150
233
 
151
234
  function manejarEventoRemoto(eventType, data) {
235
+ if (eventType === 'file-delete') {
236
+ const { ruta } = data || {};
237
+ if (!ruta) return;
238
+ const destFile = path.join(absDir, ruta);
239
+ try {
240
+ if (fs.existsSync(destFile)) {
241
+ fs.unlinkSync(destFile);
242
+ localDeletedAt.set(ruta, Date.now());
243
+ console.log(' ' + chalk.red('✖') + ' ' + chalk.white(ruta) + chalk.gray(' ← borrado remoto'));
244
+ }
245
+ } catch { console.log(' ' + chalk.red('✖') + ' No se pudo borrar: ' + ruta); }
246
+ return;
247
+ }
248
+ if (eventType === 'file-rename') {
249
+ const { ruta_antigua, ruta_nueva } = data || {};
250
+ if (!ruta_antigua || !ruta_nueva) return;
251
+ const oldFile = path.join(absDir, ruta_antigua);
252
+ const newFile = path.join(absDir, ruta_nueva);
253
+ const newDir = path.dirname(newFile);
254
+ try {
255
+ if (fs.existsSync(oldFile)) {
256
+ if (!fs.existsSync(newDir)) fs.mkdirSync(newDir, { recursive: true });
257
+ fs.renameSync(oldFile, newFile);
258
+ localDeletedAt.set(ruta_antigua, Date.now());
259
+ localUploadedAt.set(ruta_nueva, Date.now());
260
+ console.log(' ' + chalk.yellow('↔') + ' ' + chalk.white(ruta_antigua) + chalk.gray(' → ') + chalk.white(ruta_nueva));
261
+ }
262
+ } catch { console.log(' ' + chalk.red('✖') + ' No se pudo renombrar: ' + ruta_antigua); }
263
+ return;
264
+ }
152
265
  if (eventType !== 'file-update') return;
153
266
  const { ruta, contenido } = data || {};
154
267
  if (!ruta || contenido === undefined) return;
@@ -179,6 +292,7 @@ module.exports = async function sync(options) {
179
292
  try {
180
293
  if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
181
294
  fs.writeFileSync(destFile, contenido, 'utf8');
295
+ localUploadedAt.set(ruta, Date.now()); // evitar re-subida por chokidar
182
296
  console.log(' ' + chalk.cyan('↓') + ' ' + chalk.white(ruta) + chalk.gray(' ← remoto'));
183
297
  } catch {
184
298
  console.log(' ' + chalk.red('✖') + ' No se pudo escribir: ' + ruta);
@@ -234,8 +348,26 @@ module.exports = async function sync(options) {
234
348
  watcher.on('change', fp => { changed.add(fp); disparar(); });
235
349
  watcher.on('error', err => console.error(chalk.red(' [watch] Error: ' + err.message)));
236
350
 
351
+ const deletedFiles = new Set();
352
+ let deleteDebounce = null;
353
+
354
+ watcher.on('unlink', fp => {
355
+ const ruta = path.relative(absDir, fp).replace(/\\/g, '/');
356
+ if (debeIgnorar(path.basename(fp))) return;
357
+ const deletedAt = localDeletedAt.get(ruta);
358
+ if (deletedAt && Date.now() - deletedAt < 3000) return;
359
+ deletedFiles.add(fp);
360
+ clearTimeout(deleteDebounce);
361
+ deleteDebounce = setTimeout(async () => {
362
+ const files = [...deletedFiles];
363
+ deletedFiles.clear();
364
+ for (const f of files) await eliminarArchivoRemoto(uuid, absDir, f);
365
+ }, 300);
366
+ });
367
+
237
368
  process.on('SIGINT', () => {
238
369
  clearTimeout(sseReconnectTimer);
370
+ clearTimeout(deleteDebounce);
239
371
  if (sseStream) try { sseStream.destroy(); } catch {}
240
372
  watcher.close();
241
373
  console.log(chalk.gray('\n Sync detenido.\n'));
package/lib/utils/api.js CHANGED
@@ -96,4 +96,9 @@ async function streamEvents(uuid, onEvent) {
96
96
  return stream;
97
97
  }
98
98
 
99
- module.exports = { login, getProjects, deployFiles, deployWithPublish, publishProject, pullProject, getManifest, streamEvents };
99
+ async function deleteFileRemote(uuid, ruta) {
100
+ const res = await client().post(`/api/cli/delete/${uuid}`, { ruta });
101
+ return res.data;
102
+ }
103
+
104
+ module.exports = { login, getProjects, deployFiles, deployWithPublish, publishProject, pullProject, getManifest, streamEvents, deleteFileRemote };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entexto-cli",
3
- "version": "1.2.0",
3
+ "version": "1.4.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": {