shilo-cli 0.8.0 → 0.10.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/README.md +57 -0
- package/bin/shilo.js +30 -2
- package/package.json +4 -3
- package/src/ShiloClient.js +45 -6
- package/src/checks.js +57 -0
- package/src/commands/deploys.js +105 -0
- package/src/commands/diff.js +1 -1
- package/src/commands/init.js +8 -0
- package/src/commands/push.js +436 -62
- package/src/commands/status.js +112 -14
- package/src/commands/sync.js +240 -0
- package/src/folders.js +141 -0
- package/src/projectconfig.js +76 -0
- package/src/remote.js +3 -0
- package/src/safelist.js +101 -0
- package/src/secrets.js +75 -9
- package/src/sync.js +52 -4
package/src/commands/status.js
CHANGED
|
@@ -6,19 +6,29 @@ const sync = require('../sync');
|
|
|
6
6
|
const remote = require('../remote');
|
|
7
7
|
const { resolveContext } = require('../context');
|
|
8
8
|
|
|
9
|
+
// A partir de cuántos días sin sincronizar se recuerda hacerlo.
|
|
10
|
+
const STALE_DAYS = 30;
|
|
11
|
+
|
|
9
12
|
/**
|
|
10
13
|
* shilo status [carpeta] [--project <uuid>] [--base-url <url>] [--short] [--json]
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* Compara la carpeta local contra el remoto SIN bajar contenido y sin escribir
|
|
16
|
+
* nada en el servidor. Contesta las preguntas que uno se hace antes de arrancar
|
|
17
|
+
* a trabajar:
|
|
15
18
|
*
|
|
16
19
|
* - ¿el remoto tiene cambios que no tengo? (y de quién son)
|
|
17
20
|
* - ¿tengo cambios locales sin subir?
|
|
21
|
+
* - ¿hay algo mío esperando aprobación?
|
|
22
|
+
*
|
|
23
|
+
* La comparación es por ruta + tamaño (un listado por carpeta, cero descargas)
|
|
24
|
+
* y, cuando el tamaño coincide, por el SHA-256 que el remoto publica en el
|
|
25
|
+
* listado (`sync.refineByHash`): sigue sin bajar nada.
|
|
18
26
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
27
|
+
* Lo único que escribe es `.shilo.json`, y sólo cuando la carpeta coincide
|
|
28
|
+
* entera con la versión actual: ahí anota la fecha y la `base`. Es el caso de
|
|
29
|
+
* una propuesta aprobada en la web, que si no dejaba "Al día" al lado de
|
|
30
|
+
* "última sincronización hace 2 meses" y al push siguiente partiendo de una
|
|
31
|
+
* base vieja.
|
|
22
32
|
*/
|
|
23
33
|
async function status(args) {
|
|
24
34
|
const ctx = resolveContext(args);
|
|
@@ -52,8 +62,25 @@ async function status(args) {
|
|
|
52
62
|
const attribution = await sync.buildAuthorIndex(client, projectId, mgr, { commits: 50 });
|
|
53
63
|
config.persistCredsIfChanged(cfg, mgr.current);
|
|
54
64
|
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
// 2b) Propuestas abiertas (best-effort: un servidor viejo no las tiene). De
|
|
66
|
+
// paso dicen quién soy —con token personal la sesión no lo trae—, si soy
|
|
67
|
+
// el dueño, y cuál es la versión actual.
|
|
68
|
+
let listed = null;
|
|
69
|
+
try {
|
|
70
|
+
listed = await client.listProposals(projectId, await mgr.valid(), { state: 'abierta' });
|
|
71
|
+
config.persistCredsIfChanged(cfg, mgr.current);
|
|
72
|
+
} catch (_) {
|
|
73
|
+
listed = null;
|
|
74
|
+
}
|
|
75
|
+
const viewer = (listed && listed.viewer) || null;
|
|
76
|
+
|
|
77
|
+
const me =
|
|
78
|
+
cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : viewer ? viewer.id : null;
|
|
79
|
+
const cmp = sync.refineByHash(sync.compareTrees(local.files, tree.files), {
|
|
80
|
+
dir,
|
|
81
|
+
projectId,
|
|
82
|
+
baseURL,
|
|
83
|
+
});
|
|
57
84
|
|
|
58
85
|
// 3) "Detrás del remoto" = lo que otro subió y yo no tengo igual: archivos
|
|
59
86
|
// que faltan localmente, o que difieren y cambiaron después de mi lastSync.
|
|
@@ -99,6 +126,34 @@ async function status(args) {
|
|
|
99
126
|
};
|
|
100
127
|
};
|
|
101
128
|
|
|
129
|
+
// 4b) Propuestas abiertas: las mías (esperan aprobación) y, si soy el dueño,
|
|
130
|
+
// las de los demás (esperan mi decisión).
|
|
131
|
+
const openProposals = listed ? listed.proposals : [];
|
|
132
|
+
const mineOpen = me ? openProposals.filter((p) => p.author_id === me) : [];
|
|
133
|
+
const othersOpen = viewer && viewer.is_owner ? openProposals.filter((p) => p.author_id !== me) : [];
|
|
134
|
+
const projectInfo = (listed && listed.project) || null;
|
|
135
|
+
const owner = projectInfo && projectInfo.owner;
|
|
136
|
+
const ownerName = owner ? owner.display_name || owner.username : null;
|
|
137
|
+
const linkTo = (p) =>
|
|
138
|
+
projectInfo && projectInfo.path
|
|
139
|
+
? `${baseURL.replace(/\/+$/, '')}${projectInfo.path}?propuesta=${p.id.slice(0, 8)}`
|
|
140
|
+
: null;
|
|
141
|
+
|
|
142
|
+
// 4c) Carpeta idéntica a la versión actual → anotarlo (ver cabecera).
|
|
143
|
+
const aligned = behind.length === 0 && ahead.length === 0 && tree.errors.length === 0;
|
|
144
|
+
let syncedAt = lastSync;
|
|
145
|
+
if (aligned && listed && listed.main) {
|
|
146
|
+
const stale = !savedRemote || savedRemote.base !== listed.main || !lastSync;
|
|
147
|
+
if (stale && remote.touchSync(dir, { projectId, baseURL, base: listed.main })) {
|
|
148
|
+
syncedAt = new Date().toISOString();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 4d) Hace cuánto que esta carpeta no se alinea con el proyecto.
|
|
153
|
+
const daysSinceSync = syncedAt
|
|
154
|
+
? Math.floor((Date.now() - new Date(syncedAt).getTime()) / 86400000)
|
|
155
|
+
: null;
|
|
156
|
+
|
|
102
157
|
if (asJson) {
|
|
103
158
|
console.log(
|
|
104
159
|
JSON.stringify(
|
|
@@ -106,7 +161,9 @@ async function status(args) {
|
|
|
106
161
|
projectId,
|
|
107
162
|
baseURL,
|
|
108
163
|
dir,
|
|
109
|
-
lastSync:
|
|
164
|
+
lastSync: syncedAt || null,
|
|
165
|
+
pendingProposals: mineOpen.map((p) => ({ id: p.id, title: p.title, createdAt: p.created_at, url: linkTo(p) })),
|
|
166
|
+
awaitingMyDecision: othersOpen.map((p) => ({ id: p.id, title: p.title, url: linkTo(p) })),
|
|
110
167
|
remoteFiles: tree.files.length,
|
|
111
168
|
localFiles: local.files.length,
|
|
112
169
|
behind: behind.map((i) => ({ path: i.repoPath, kind: i.kind, lastCommit: decorate(i) })),
|
|
@@ -130,8 +187,8 @@ async function status(args) {
|
|
|
130
187
|
}
|
|
131
188
|
|
|
132
189
|
ui.info('');
|
|
133
|
-
if (
|
|
134
|
-
ui.info(ui.c.dim(`Última sincronización de esta carpeta: ${sync.formatDate(
|
|
190
|
+
if (syncedAt) {
|
|
191
|
+
ui.info(ui.c.dim(`Última sincronización de esta carpeta: ${sync.formatDate(syncedAt)}`));
|
|
135
192
|
} else {
|
|
136
193
|
ui.info(
|
|
137
194
|
ui.c.dim(
|
|
@@ -157,8 +214,35 @@ async function status(args) {
|
|
|
157
214
|
ui.info('');
|
|
158
215
|
};
|
|
159
216
|
|
|
217
|
+
/** Propuestas abiertas: lo primero que hay que saber, antes que la lista de archivos. */
|
|
218
|
+
const reportProposals = () => {
|
|
219
|
+
if (mineOpen.length > 0) {
|
|
220
|
+
const who = viewer && viewer.is_owner ? 'tu aprobación' : ownerName ? `la aprobación de ${ownerName}` : 'aprobación';
|
|
221
|
+
ui.info(ui.c.bold(`Tenés ${mineOpen.length} propuesta(s) esperando ${who}:`));
|
|
222
|
+
for (const p of mineOpen) {
|
|
223
|
+
const link = linkTo(p);
|
|
224
|
+
ui.info(
|
|
225
|
+
` ${ui.c.yellow('⧗')} ${p.title || p.id.slice(0, 8)} ${ui.c.dim(`— ${sync.formatDate(p.created_at)}`)}` +
|
|
226
|
+
(link ? `\n ${ui.c.cyan(link)}` : '')
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
ui.info('');
|
|
230
|
+
}
|
|
231
|
+
if (othersOpen.length > 0) {
|
|
232
|
+
ui.info(ui.c.bold(`${othersOpen.length} propuesta(s) de otras personas esperan tu decisión:`));
|
|
233
|
+
for (const p of othersOpen) {
|
|
234
|
+
const by = p.author ? p.author.display_name || p.author.username : 'alguien';
|
|
235
|
+
const link = linkTo(p);
|
|
236
|
+
ui.info(` ${ui.c.yellow('⧗')} ${p.title || p.id.slice(0, 8)} ${ui.c.dim(`— de ${by}`)}` + (link ? `\n ${ui.c.cyan(link)}` : ''));
|
|
237
|
+
}
|
|
238
|
+
ui.info('');
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
reportProposals();
|
|
243
|
+
|
|
160
244
|
if (behind.length === 0 && ahead.length === 0) {
|
|
161
|
-
ui.success(`Al día: ${cmp.same.length} archivo(s) coinciden con el
|
|
245
|
+
ui.success(`Al día: ${cmp.same.length} archivo(s) coinciden con el proyecto.`);
|
|
162
246
|
ui.info('');
|
|
163
247
|
reportIgnored();
|
|
164
248
|
return 0;
|
|
@@ -182,7 +266,10 @@ async function status(args) {
|
|
|
182
266
|
}
|
|
183
267
|
|
|
184
268
|
if (ahead.length > 0) {
|
|
185
|
-
ui.info(
|
|
269
|
+
ui.info(
|
|
270
|
+
ui.c.bold(`Tenés ${ahead.length} cambio(s) local(es) que todavía no son parte del proyecto`) +
|
|
271
|
+
(mineOpen.length > 0 ? ui.c.bold(' (pueden estar en tu propuesta pendiente):') : ui.c.bold(':'))
|
|
272
|
+
);
|
|
186
273
|
for (const item of ahead) {
|
|
187
274
|
const mark = item.kind === 'nuevo local' ? ui.c.green('+') : ui.c.yellow('~');
|
|
188
275
|
ui.info(` ${mark} ${item.repoPath}`);
|
|
@@ -194,7 +281,7 @@ async function status(args) {
|
|
|
194
281
|
|
|
195
282
|
ui.info(
|
|
196
283
|
ui.c.dim(
|
|
197
|
-
` ${cmp.same.length} sin cambios
|
|
284
|
+
` ${cmp.same.length} sin cambios ` +
|
|
198
285
|
`${local.ignored} ignorados ${local.skipped.length} saltados`
|
|
199
286
|
)
|
|
200
287
|
);
|
|
@@ -211,10 +298,21 @@ async function status(args) {
|
|
|
211
298
|
ui.info(ui.c.dim('Mirá "shilo diff <archivo>" antes de decidir.'));
|
|
212
299
|
} else if (behind.length > 0) {
|
|
213
300
|
ui.info(ui.c.dim('Sugerido: "shilo pull --dry-run" para ver qué bajaría.'));
|
|
301
|
+
} else if (mineOpen.length > 0) {
|
|
302
|
+
ui.info(ui.c.dim('Tus cambios ya están guardados: falta que se apruebe la propuesta de arriba.'));
|
|
214
303
|
} else {
|
|
215
304
|
ui.info(ui.c.dim('Sugerido: "shilo push --dry-run" para ver qué subiría.'));
|
|
216
305
|
}
|
|
217
306
|
|
|
307
|
+
// Recordatorio: sincronizar seguido da propuestas chicas y fáciles de revisar.
|
|
308
|
+
if (ahead.length > 0 && daysSinceSync !== null && daysSinceSync >= STALE_DAYS) {
|
|
309
|
+
ui.info('');
|
|
310
|
+
ui.warn(
|
|
311
|
+
`Hace ${daysSinceSync} días que esta carpeta no se sincroniza. Subí seguido: ` +
|
|
312
|
+
'cuanto más chico el cambio, más fácil de revisar y menos choques con lo de otros.'
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
218
316
|
// Exit code 2 = "estás desactualizado". Útil para encadenar en scripts:
|
|
219
317
|
// `shilo status || echo "bajá antes de trabajar"`.
|
|
220
318
|
return behind.length > 0 ? 2 : 0;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `shilo sync` — respaldo de las carpetas conectadas (PLAN_SINCRONIZAR, Etapa 1).
|
|
5
|
+
*
|
|
6
|
+
* shilo sync lista las carpetas registradas y su último sync
|
|
7
|
+
* shilo sync --all sube todas, una tras otra
|
|
8
|
+
* shilo sync <carpeta|uuid> sube sólo esa (por ruta o por id de proyecto)
|
|
9
|
+
* shilo sync --scan [raíz] registra las carpetas atadas que haya bajo raíz
|
|
10
|
+
* shilo sync --forget <dir> saca una carpeta del registro (no toca el disco)
|
|
11
|
+
*
|
|
12
|
+
* Sincronizar es SUBIR: cada carpeta pasa por `shilo push --yes`, con todas sus
|
|
13
|
+
* garantías — un conflicto queda como propuesta abierta sin pisar a nadie, y una
|
|
14
|
+
* carpeta con posibles secretos no sube nada. Cada push corre en su propio
|
|
15
|
+
* proceso: lo que falle en una carpeta no arrastra a las demás.
|
|
16
|
+
*
|
|
17
|
+
* Una carpeta sin `base` en su `.shilo.json` se saltea (ver `checkFolder`).
|
|
18
|
+
*
|
|
19
|
+
* Un respaldo no despliega: se manda `--deploy off` salvo que se pida otra cosa,
|
|
20
|
+
* porque con `auto_target = production` un respaldo nocturno iría a producción.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const { spawn } = require('child_process');
|
|
26
|
+
const folders = require('../folders');
|
|
27
|
+
const remote = require('../remote');
|
|
28
|
+
const ui = require('../ui');
|
|
29
|
+
|
|
30
|
+
const BIN = path.join(__dirname, '..', '..', 'bin', 'shilo.js');
|
|
31
|
+
// Un prefijo del id alcanza (como un hash corto de git), pero no menos de 4.
|
|
32
|
+
const UUID_RE = /^[0-9a-f-]{4,36}$/i;
|
|
33
|
+
|
|
34
|
+
async function sync(args) {
|
|
35
|
+
if (args.forget !== undefined) return forget(args.forget);
|
|
36
|
+
if (args.scan !== undefined) return scan(args.scan === true ? '.' : args.scan);
|
|
37
|
+
|
|
38
|
+
let entries;
|
|
39
|
+
try {
|
|
40
|
+
entries = folders.load();
|
|
41
|
+
} catch (e) {
|
|
42
|
+
ui.error(e.message);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const target = args._[0];
|
|
47
|
+
if (!args.all && !target) return list(entries);
|
|
48
|
+
|
|
49
|
+
let selected;
|
|
50
|
+
if (args.all) {
|
|
51
|
+
selected = entries;
|
|
52
|
+
} else {
|
|
53
|
+
selected = resolveTarget(target, entries);
|
|
54
|
+
if (!selected) return 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (selected.length === 0) {
|
|
58
|
+
ui.warn('No hay carpetas registradas.');
|
|
59
|
+
ui.info('Corré "shilo sync --scan <carpeta>" para encontrar las que ya están conectadas.');
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const deploy = typeof args.deploy === 'string' ? args.deploy : 'off';
|
|
64
|
+
const message =
|
|
65
|
+
typeof args.message === 'string' && args.message.trim()
|
|
66
|
+
? args.message.trim()
|
|
67
|
+
: `Respaldo (shilo sync) ${new Date().toLocaleString('es-AR')}`;
|
|
68
|
+
|
|
69
|
+
const results = [];
|
|
70
|
+
for (const [i, entry] of selected.entries()) {
|
|
71
|
+
ui.info('');
|
|
72
|
+
ui.info(ui.c.bold(`[${i + 1}/${selected.length}] ${entry.dir}`));
|
|
73
|
+
|
|
74
|
+
const check = checkFolder(entry);
|
|
75
|
+
if (check.problem) {
|
|
76
|
+
ui.warn(check.problem);
|
|
77
|
+
results.push({ dir: entry.dir, status: 'salteada', detail: check.problem });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const pushArgs = ['push', entry.dir, '--yes', '-m', message, '--deploy', deploy];
|
|
82
|
+
if (args['dry-run']) pushArgs.push('--dry-run');
|
|
83
|
+
const code = await runChild(pushArgs);
|
|
84
|
+
results.push(resultFor(entry.dir, code));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return summarize(results, !!args['dry-run']);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Código de salida de `push` → fila del resumen. */
|
|
91
|
+
function resultFor(dir, code) {
|
|
92
|
+
if (code === 0) return { dir, status: 'ok', detail: '' };
|
|
93
|
+
// 3 = todo guardado, pero el proyecto exige aprobación: el respaldo está hecho.
|
|
94
|
+
if (code === 3) return { dir, status: 'ok', detail: 'guardado; la propuesta espera aprobación' };
|
|
95
|
+
return { dir, status: 'falló', detail: `push salió con código ${code} (ver arriba)` };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Lista el registro con el estado que dice cada `.shilo.json`. */
|
|
99
|
+
function list(entries) {
|
|
100
|
+
if (entries.length === 0) {
|
|
101
|
+
ui.info('No hay carpetas registradas todavía.');
|
|
102
|
+
ui.info(
|
|
103
|
+
ui.c.dim(
|
|
104
|
+
'Se registran solas con init, push o pull; las ya conectadas se suman con "shilo sync --scan <carpeta>".'
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
ui.info(ui.c.bold(`Carpetas conectadas (${entries.length}):`));
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
const check = checkFolder(entry);
|
|
112
|
+
const when = check.remote && check.remote.lastSync
|
|
113
|
+
? new Date(check.remote.lastSync).toLocaleString('es-AR')
|
|
114
|
+
: 'nunca';
|
|
115
|
+
const state = check.problem ? ui.c.yellow(check.problem) : ui.c.dim(`último sync: ${when}`);
|
|
116
|
+
ui.info(` ${entry.dir}`);
|
|
117
|
+
ui.info(` ${ui.c.dim(entry.projectId || '?')} ${state}`);
|
|
118
|
+
}
|
|
119
|
+
ui.info('');
|
|
120
|
+
ui.info(ui.c.dim('"shilo sync --all" las sube todas; "shilo sync <carpeta|uuid>" sólo una.'));
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `<carpeta|uuid>` → lista de una entrada. Una carpeta existente que no estaba
|
|
126
|
+
* en el registro se registra (si está atada).
|
|
127
|
+
*/
|
|
128
|
+
function resolveTarget(target, entries) {
|
|
129
|
+
const abs = path.resolve(target);
|
|
130
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
|
131
|
+
const r = safeRead(abs);
|
|
132
|
+
if (!r || !r.projectId) {
|
|
133
|
+
ui.error(`La carpeta no está conectada a Shilo (no tiene .shilo.json): ${abs}`);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
folders.add(abs, r.projectId);
|
|
137
|
+
return [{ dir: abs, projectId: r.projectId }];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (UUID_RE.test(target)) {
|
|
141
|
+
const t = target.toLowerCase();
|
|
142
|
+
const matches = entries.filter((e) => e.projectId && e.projectId.toLowerCase().startsWith(t));
|
|
143
|
+
if (matches.length > 0) return matches; // un proyecto atado en dos carpetas: las dos
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
ui.error(`No encontré "${target}" ni como carpeta ni como proyecto registrado.`);
|
|
147
|
+
ui.info('Corré "shilo sync" para ver la lista.');
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** ¿Se puede sincronizar? Devuelve `{ remote, problem }`. */
|
|
152
|
+
function checkFolder(entry) {
|
|
153
|
+
if (!fs.existsSync(entry.dir)) {
|
|
154
|
+
return { remote: null, problem: 'la carpeta ya no existe ("shilo sync --forget" para sacarla)' };
|
|
155
|
+
}
|
|
156
|
+
const r = safeRead(entry.dir);
|
|
157
|
+
if (!r || !r.projectId) {
|
|
158
|
+
return { remote: null, problem: 'ya no tiene .shilo.json (¿se desconectó?)' };
|
|
159
|
+
}
|
|
160
|
+
if (r.projectId !== entry.projectId) folders.addQuietly(entry.dir, r.projectId);
|
|
161
|
+
// Sin `base`, el push toma el main actual como base y trata como cambios
|
|
162
|
+
// todos los archivos locales que difieren: una copia vieja pisaría trabajo
|
|
163
|
+
// más nuevo (pasó el 2026-10-12). Eso no puede pasar sin que alguien mire.
|
|
164
|
+
if (!r.base) return { remote: r, problem: NO_BASE };
|
|
165
|
+
return { remote: r, problem: null };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const NO_BASE =
|
|
169
|
+
'requiere atención: sin "base" en .shilo.json no sé si está al día. Revisala con ' +
|
|
170
|
+
'"shilo status" y "shilo diff", y subila a mano una vez con "shilo push" (o "shilo pull" si está atrasada)';
|
|
171
|
+
|
|
172
|
+
function safeRead(dir) {
|
|
173
|
+
try {
|
|
174
|
+
return remote.read(dir);
|
|
175
|
+
} catch (_) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function scan(root) {
|
|
181
|
+
const abs = path.resolve(root);
|
|
182
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
|
183
|
+
ui.error(`No es una carpeta válida: ${abs}`);
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
186
|
+
ui.info(ui.c.dim(`Buscando carpetas conectadas bajo ${abs}…`));
|
|
187
|
+
const found = folders.scan(abs);
|
|
188
|
+
let added = 0;
|
|
189
|
+
for (const f of found) {
|
|
190
|
+
const isNew = folders.add(f.dir, f.projectId);
|
|
191
|
+
if (isNew) added++;
|
|
192
|
+
ui.info(` ${isNew ? ui.c.green('+') : ui.c.dim('=')} ${f.dir}`);
|
|
193
|
+
}
|
|
194
|
+
if (found.length === 0) ui.info('No encontré ninguna carpeta con .shilo.json.');
|
|
195
|
+
else ui.success(`${found.length} encontrada(s), ${added} nueva(s) en el registro.`);
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function forget(dir) {
|
|
200
|
+
if (dir === true) {
|
|
201
|
+
ui.error('Falta la carpeta: shilo sync --forget <carpeta>');
|
|
202
|
+
return 1;
|
|
203
|
+
}
|
|
204
|
+
if (folders.remove(dir)) {
|
|
205
|
+
ui.success(`Sacada del registro: ${path.resolve(dir)}`);
|
|
206
|
+
ui.info(ui.c.dim('No se tocó nada en disco; un push o pull futuro la vuelve a registrar.'));
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
ui.warn(`No estaba en el registro: ${path.resolve(dir)}`);
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function runChild(argv) {
|
|
214
|
+
return new Promise((resolve) => {
|
|
215
|
+
const child = spawn(process.execPath, [BIN, ...argv], { stdio: 'inherit' });
|
|
216
|
+
child.on('error', (e) => {
|
|
217
|
+
ui.error(`No se pudo lanzar el push: ${e.message}`);
|
|
218
|
+
resolve(1);
|
|
219
|
+
});
|
|
220
|
+
child.on('close', (code) => resolve(typeof code === 'number' ? code : 1));
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function summarize(results, dryRun) {
|
|
225
|
+
ui.info('');
|
|
226
|
+
ui.info(ui.c.bold('Resumen del respaldo:'));
|
|
227
|
+
for (const r of results) {
|
|
228
|
+
const mark =
|
|
229
|
+
r.status === 'ok' ? ui.c.green('✓') : r.status === 'salteada' ? ui.c.yellow('!') : ui.c.red('✗');
|
|
230
|
+
ui.info(` ${mark} ${r.dir}${r.detail ? ui.c.dim(` — ${r.detail}`) : ''}`);
|
|
231
|
+
}
|
|
232
|
+
const ok = results.filter((r) => r.status === 'ok').length;
|
|
233
|
+
const bad = results.length - ok;
|
|
234
|
+
const verb = dryRun ? 'revisada(s) (--dry-run: no se subió nada)' : 'respaldada(s)';
|
|
235
|
+
if (bad === 0) ui.success(`${ok} carpeta(s) ${verb}.`);
|
|
236
|
+
else ui.warn(`${ok} ${verb}, ${bad} con problemas.`);
|
|
237
|
+
return bad === 0 ? 0 : 1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = sync;
|
package/src/folders.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Registro local de carpetas conectadas: `~/.shilo/carpetas.json`.
|
|
5
|
+
*
|
|
6
|
+
* El servidor conoce los proyectos, pero no en qué carpetas de qué máquina
|
|
7
|
+
* está atado cada uno: eso sólo lo sabe el disco (cada `.shilo.json`). Este
|
|
8
|
+
* registro es la lista que `shilo sync --all` recorre (PLAN_SINCRONIZAR, Etapa 1).
|
|
9
|
+
*
|
|
10
|
+
* Se llena solo: `remote.write` —el único lugar donde se escribe un
|
|
11
|
+
* `.shilo.json`, lo usen `init`, `pull` o `push`— registra la carpeta. Las que
|
|
12
|
+
* se ataron antes de que existiera el registro se suman con `shilo sync --scan`.
|
|
13
|
+
*
|
|
14
|
+
* Estructura en disco:
|
|
15
|
+
* {
|
|
16
|
+
* "version": 1,
|
|
17
|
+
* "carpetas": [
|
|
18
|
+
* { "dir": "C:\\...\\boda", "projectId": "<uuid>", "addedAt": "<ISO>" }
|
|
19
|
+
* ]
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* No guarda credenciales ni el estado de sincronización: el estado vive en el
|
|
23
|
+
* `.shilo.json` de cada carpeta (`lastSync`), que es la fuente de verdad.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const config = require('./config');
|
|
29
|
+
const remote = require('./remote');
|
|
30
|
+
|
|
31
|
+
const FILENAME = 'carpetas.json';
|
|
32
|
+
|
|
33
|
+
// Lo que `--scan` no recorre: pesado y nunca contiene una carpeta atada propia.
|
|
34
|
+
const SCAN_SKIP = new Set(['node_modules', '.git', '.shilo', '.vercel', 'dist', 'build']);
|
|
35
|
+
|
|
36
|
+
function registryPath() {
|
|
37
|
+
return path.join(config.configDir(), FILENAME);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** En Windows y macOS las rutas no distinguen mayúsculas: una carpeta, una entrada. */
|
|
41
|
+
function keyOf(dir) {
|
|
42
|
+
const abs = path.resolve(dir);
|
|
43
|
+
return process.platform === 'linux' ? abs : abs.toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function load() {
|
|
47
|
+
const p = registryPath();
|
|
48
|
+
if (!fs.existsSync(p)) return [];
|
|
49
|
+
let raw;
|
|
50
|
+
try {
|
|
51
|
+
raw = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
|
|
52
|
+
} catch (e) {
|
|
53
|
+
throw new Error(`Registro de carpetas corrupto en ${p}: ${e.message}`);
|
|
54
|
+
}
|
|
55
|
+
return Array.isArray(raw.carpetas) ? raw.carpetas.filter((c) => c && typeof c.dir === 'string') : [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function save(list) {
|
|
59
|
+
fs.mkdirSync(config.configDir(), { recursive: true });
|
|
60
|
+
const p = registryPath();
|
|
61
|
+
fs.writeFileSync(p, JSON.stringify({ version: 1, carpetas: list }, null, 2) + '\n', 'utf8');
|
|
62
|
+
return p;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Registra (o actualiza el proyecto de) una carpeta. Devuelve `true` si era nueva.
|
|
67
|
+
*/
|
|
68
|
+
function add(dir, projectId) {
|
|
69
|
+
const abs = path.resolve(dir);
|
|
70
|
+
const list = load();
|
|
71
|
+
const k = keyOf(abs);
|
|
72
|
+
const found = list.find((c) => keyOf(c.dir) === k);
|
|
73
|
+
if (found) {
|
|
74
|
+
if (projectId && found.projectId !== projectId) {
|
|
75
|
+
found.projectId = projectId;
|
|
76
|
+
save(list);
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
list.push({ dir: abs, projectId: projectId || null, addedAt: new Date().toISOString() });
|
|
81
|
+
save(list);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Versión para `remote.write`: registrar nunca puede romper un push o un pull
|
|
87
|
+
* (config de sólo lectura, registro corrupto, etc.).
|
|
88
|
+
*/
|
|
89
|
+
function addQuietly(dir, projectId) {
|
|
90
|
+
try {
|
|
91
|
+
add(dir, projectId);
|
|
92
|
+
} catch (_) {
|
|
93
|
+
/* el registro es una comodidad, no un requisito */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Saca una carpeta del registro. Devuelve `true` si estaba. */
|
|
98
|
+
function remove(dir) {
|
|
99
|
+
const k = keyOf(dir);
|
|
100
|
+
const list = load();
|
|
101
|
+
const rest = list.filter((c) => keyOf(c.dir) !== k);
|
|
102
|
+
if (rest.length === list.length) return false;
|
|
103
|
+
save(rest);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Busca `.shilo.json` bajo `root` (incluida) hasta `maxDepth` niveles.
|
|
109
|
+
* No entra en una carpeta atada: un repo de Shilo no contiene otro.
|
|
110
|
+
* Devuelve `[{ dir, projectId }]`.
|
|
111
|
+
*/
|
|
112
|
+
function scan(root, { maxDepth = 6 } = {}) {
|
|
113
|
+
const found = [];
|
|
114
|
+
const walk = (dir, depth) => {
|
|
115
|
+
let info = null;
|
|
116
|
+
try {
|
|
117
|
+
info = remote.read(dir);
|
|
118
|
+
} catch (_) {
|
|
119
|
+
/* `.shilo.json` corrupto: no es una carpeta sincronizable */
|
|
120
|
+
}
|
|
121
|
+
if (info && info.projectId) {
|
|
122
|
+
found.push({ dir, projectId: info.projectId });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (depth >= maxDepth) return;
|
|
126
|
+
let entries;
|
|
127
|
+
try {
|
|
128
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
129
|
+
} catch (_) {
|
|
130
|
+
return; // sin permiso de lectura
|
|
131
|
+
}
|
|
132
|
+
for (const e of entries) {
|
|
133
|
+
if (!e.isDirectory() || SCAN_SKIP.has(e.name)) continue;
|
|
134
|
+
walk(path.join(dir, e.name), depth + 1);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
walk(path.resolve(root), 0);
|
|
138
|
+
return found;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { registryPath, load, add, addQuietly, remove, scan, keyOf };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shilo.config.json` del lado del CLI (EP-S2): **espejo** de
|
|
3
|
+
* `backend/utils/projectConfig.js`, que es donde está explicado el porqué.
|
|
4
|
+
* El CLI no puede importar del backend; `backend/utils/__tests__/projectConfig.test.js`
|
|
5
|
+
* corre los dos sobre las mismas entradas para que no se separen.
|
|
6
|
+
*
|
|
7
|
+
* El CLI la lee de la carpeta local: los chequeos (MU-93) se corren antes de
|
|
8
|
+
* proponer, con lo que hay en disco.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const CONFIG_PATH = 'shilo.config.json';
|
|
14
|
+
const UNIONES = ['archivo', 'lineas'];
|
|
15
|
+
const MAX_CHEQUEOS = 10;
|
|
16
|
+
const MAX_COMANDO = 500;
|
|
17
|
+
|
|
18
|
+
/** Los comandos válidos de `chequeos`, anotando en `avisos` lo que se ignora. */
|
|
19
|
+
function parseChequeos(raw, avisos) {
|
|
20
|
+
if (!Array.isArray(raw)) {
|
|
21
|
+
avisos.push('"chequeos" debe ser una lista de comandos');
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const cmd of raw) {
|
|
26
|
+
if (typeof cmd !== 'string' || !cmd.trim() || cmd.length > MAX_COMANDO) {
|
|
27
|
+
avisos.push('Se ignoró un chequeo que no es un comando de texto');
|
|
28
|
+
} else if (out.length >= MAX_CHEQUEOS) {
|
|
29
|
+
avisos.push(`Más de ${MAX_CHEQUEOS} chequeos: se ignoran los demás`);
|
|
30
|
+
break;
|
|
31
|
+
} else {
|
|
32
|
+
out.push(cmd.trim());
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** El objeto de `shilo.config.json`, o null con el aviso de por qué no. */
|
|
39
|
+
function parseObject(text, avisos) {
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
const clean = String(text);
|
|
43
|
+
// Sin BOM: JSON.parse no lo acepta y algunos editores de Windows lo ponen.
|
|
44
|
+
raw = JSON.parse(clean.charCodeAt(0) === 0xfeff ? clean.slice(1) : clean);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
avisos.push(`${CONFIG_PATH} no es JSON válido: se usan los valores por defecto`);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
50
|
+
avisos.push(`${CONFIG_PATH} debe ser un objeto: se usan los valores por defecto`);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return raw;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string|null|undefined} text - contenido de `shilo.config.json`
|
|
58
|
+
* @returns {{ union: 'archivo'|'lineas', chequeos: string[], avisos: string[] }}
|
|
59
|
+
*/
|
|
60
|
+
function parseProjectConfig(text) {
|
|
61
|
+
const config = { union: 'archivo', chequeos: [], avisos: [] };
|
|
62
|
+
if (text === null || text === undefined || String(text).trim() === '') return config;
|
|
63
|
+
|
|
64
|
+
const raw = parseObject(text, config.avisos);
|
|
65
|
+
if (!raw) return config;
|
|
66
|
+
|
|
67
|
+
if (raw.union !== undefined) {
|
|
68
|
+
if (UNIONES.includes(raw.union)) config.union = raw.union;
|
|
69
|
+
else config.avisos.push(`"union" debe ser ${UNIONES.join(' o ')}: se usa "archivo"`);
|
|
70
|
+
}
|
|
71
|
+
if (raw.chequeos !== undefined) config.chequeos = parseChequeos(raw.chequeos, config.avisos);
|
|
72
|
+
|
|
73
|
+
return config;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { CONFIG_PATH, UNIONES, MAX_CHEQUEOS, parseProjectConfig };
|
package/src/remote.js
CHANGED
|
@@ -72,6 +72,9 @@ function write(dir, { projectId, baseURL, lastSync, base }) {
|
|
|
72
72
|
if (lastSync) onDisk.lastSync = lastSync;
|
|
73
73
|
if (base) onDisk.base = base;
|
|
74
74
|
fs.writeFileSync(p, JSON.stringify(onDisk, null, 2) + '\n', 'utf8');
|
|
75
|
+
// Toda carpeta atada queda en el registro de `shilo sync --all`. Require
|
|
76
|
+
// perezoso: `folders` ya depende de este módulo.
|
|
77
|
+
require('./folders').addQuietly(dir, projectId);
|
|
75
78
|
return p;
|
|
76
79
|
}
|
|
77
80
|
|