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 +10 -0
- package/lib/commands/deploy.js +205 -203
- package/lib/commands/sync.js +314 -0
- package/lib/utils/api.js +33 -1
- package/lib/utils/delta.js +26 -0
- package/package.json +1 -1
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) {
|
package/lib/commands/deploy.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
'.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
return
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const resultado = [];
|
|
38
|
-
let entries;
|
|
39
|
-
try {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
watcher.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
await
|
|
197
|
-
}, 300);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
watcher.on('
|
|
201
|
-
|
|
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
|
-
|
|
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 };
|