entexto-cli 1.1.0 → 1.2.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 +244 -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,244 @@
|
|
|
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, 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: subir solo cambios locales → remoto ───────────────
|
|
107
|
+
console.log(chalk.bold('\n🔄 Delta inicial...\n'));
|
|
108
|
+
const todosArchivos = recolectarArchivos(absDir);
|
|
109
|
+
let manifest = {};
|
|
110
|
+
try {
|
|
111
|
+
manifest = await getManifest(uuid);
|
|
112
|
+
} catch {
|
|
113
|
+
console.log(chalk.gray(' (manifest no disponible, comparando todos)'));
|
|
114
|
+
}
|
|
115
|
+
|
|
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) {
|
|
121
|
+
console.log(' ' + chalk.gray('──') + ' ' + chalk.gray(ruta));
|
|
122
|
+
} else {
|
|
123
|
+
aSubir.push({ filePath, ruta });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (aSubir.length > 0) {
|
|
128
|
+
const sp = ora(`Subiendo ${aSubir.length} archivo(s) modificados...`).start();
|
|
129
|
+
try {
|
|
130
|
+
const form = new FormData();
|
|
131
|
+
for (const { filePath, ruta } of aSubir) {
|
|
132
|
+
form.append(ruta, fs.createReadStream(filePath), { filename: ruta });
|
|
133
|
+
localUploadedAt.set(ruta, Date.now());
|
|
134
|
+
}
|
|
135
|
+
await deployFiles(uuid, form, false);
|
|
136
|
+
sp.succeed(`${aSubir.length} archivo(s) subidos`);
|
|
137
|
+
aSubir.forEach(({ ruta }) => console.log(' ' + chalk.green('↑') + ' ' + chalk.white(ruta)));
|
|
138
|
+
} catch (err) {
|
|
139
|
+
sp.fail('Error en delta inicial: ' + (err.response?.data?.error || err.message));
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
console.log(chalk.green(' ✔ Todo sincronizado, sin cambios locales.\n'));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── 2. Canal SSE (remoto → local) ───────────────────────────────────────
|
|
146
|
+
console.log(chalk.bold.cyan('\n🔗 Abriendo canal bidireccional...\n'));
|
|
147
|
+
|
|
148
|
+
let sseStream = null;
|
|
149
|
+
let sseReconnectTimer = null;
|
|
150
|
+
|
|
151
|
+
function manejarEventoRemoto(eventType, data) {
|
|
152
|
+
if (eventType !== 'file-update') return;
|
|
153
|
+
const { ruta, contenido } = data || {};
|
|
154
|
+
if (!ruta || contenido === undefined) return;
|
|
155
|
+
|
|
156
|
+
// Evitar eco: si acabamos de subir este archivo (< 3s), ignorar
|
|
157
|
+
const uploadedAt = localUploadedAt.get(ruta);
|
|
158
|
+
if (uploadedAt && Date.now() - uploadedAt < 3000) return;
|
|
159
|
+
|
|
160
|
+
const destFile = path.join(absDir, ruta);
|
|
161
|
+
const destDir = path.dirname(destFile);
|
|
162
|
+
|
|
163
|
+
// Detección de conflicto: archivo local modificado hace < 5s Y diferente al remoto
|
|
164
|
+
try {
|
|
165
|
+
if (fs.existsSync(destFile)) {
|
|
166
|
+
const stat = fs.statSync(destFile);
|
|
167
|
+
const localHash = hashFile(destFile);
|
|
168
|
+
const remHash = hashContent(contenido);
|
|
169
|
+
if (localHash !== remHash && Date.now() - stat.mtimeMs < 5000) {
|
|
170
|
+
const conflictPath = destFile + '.conflict.' + Date.now();
|
|
171
|
+
fs.writeFileSync(conflictPath, contenido, 'utf8');
|
|
172
|
+
console.log(' ' + chalk.yellow('⚠') + ' CONFLICTO ' + chalk.white(ruta));
|
|
173
|
+
console.log(' Versión remota guardada → ' + chalk.gray(path.basename(conflictPath)));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} catch {}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
181
|
+
fs.writeFileSync(destFile, contenido, 'utf8');
|
|
182
|
+
console.log(' ' + chalk.cyan('↓') + ' ' + chalk.white(ruta) + chalk.gray(' ← remoto'));
|
|
183
|
+
} catch {
|
|
184
|
+
console.log(' ' + chalk.red('✖') + ' No se pudo escribir: ' + ruta);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function conectarSSE() {
|
|
189
|
+
streamEvents(uuid, manejarEventoRemoto)
|
|
190
|
+
.then(stream => {
|
|
191
|
+
sseStream = stream;
|
|
192
|
+
console.log(chalk.green(' ✔ SSE conectado — escuchando cambios remotos\n'));
|
|
193
|
+
stream.on('error', () => {
|
|
194
|
+
console.log(chalk.yellow('\n ⚠ SSE desconectado, reconectando en 5s...'));
|
|
195
|
+
sseReconnectTimer = setTimeout(conectarSSE, 5000);
|
|
196
|
+
});
|
|
197
|
+
stream.on('close', () => {
|
|
198
|
+
console.log(chalk.yellow('\n ⚠ SSE cerrado, reconectando en 5s...'));
|
|
199
|
+
sseReconnectTimer = setTimeout(conectarSSE, 5000);
|
|
200
|
+
});
|
|
201
|
+
})
|
|
202
|
+
.catch(err => {
|
|
203
|
+
console.log(chalk.yellow(' ⚠ SSE no disponible: ' + err.message));
|
|
204
|
+
sseReconnectTimer = setTimeout(conectarSSE, 5000);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
conectarSSE();
|
|
209
|
+
|
|
210
|
+
// ── 3. Watch local → remoto ─────────────────────────────────────────────
|
|
211
|
+
console.log(chalk.bold('👀 Watching: ' + absDir));
|
|
212
|
+
console.log(chalk.gray(' Ctrl+C para detener\n'));
|
|
213
|
+
|
|
214
|
+
let debounce = null;
|
|
215
|
+
const changed = new Set();
|
|
216
|
+
|
|
217
|
+
const watcher = chokidar.watch(absDir, {
|
|
218
|
+
ignored: (fp) => debeIgnorar(path.basename(fp)),
|
|
219
|
+
ignoreInitial: true,
|
|
220
|
+
usePolling: false,
|
|
221
|
+
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
function disparar() {
|
|
225
|
+
clearTimeout(debounce);
|
|
226
|
+
debounce = setTimeout(async () => {
|
|
227
|
+
const files = [...changed];
|
|
228
|
+
changed.clear();
|
|
229
|
+
for (const fp of files) await subirArchivoSolo(uuid, absDir, fp);
|
|
230
|
+
}, 300);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
watcher.on('add', fp => { changed.add(fp); disparar(); });
|
|
234
|
+
watcher.on('change', fp => { changed.add(fp); disparar(); });
|
|
235
|
+
watcher.on('error', err => console.error(chalk.red(' [watch] Error: ' + err.message)));
|
|
236
|
+
|
|
237
|
+
process.on('SIGINT', () => {
|
|
238
|
+
clearTimeout(sseReconnectTimer);
|
|
239
|
+
if (sseStream) try { sseStream.destroy(); } catch {}
|
|
240
|
+
watcher.close();
|
|
241
|
+
console.log(chalk.gray('\n Sync detenido.\n'));
|
|
242
|
+
process.exit(0);
|
|
243
|
+
});
|
|
244
|
+
};
|
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 };
|