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.
@@ -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
- * Comando de sólo lectura: compara la carpeta local contra el remoto SIN bajar
13
- * contenido y sin escribir nada, ni local ni remoto. Contesta las dos preguntas
14
- * que uno se hace antes de arrancar a trabajar:
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
- * La comparación es por ruta + tamaño (un listado por carpeta, cero descargas).
20
- * Dos archivos del mismo tamaño se reportan como "iguales" aunque su contenido
21
- * pudiera diferir: para confirmar byte a byte están `shilo diff` y `push`.
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
- const me = cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : null;
56
- const cmp = sync.compareTrees(local.files, tree.files);
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: lastSync || null,
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 (lastSync) {
134
- ui.info(ui.c.dim(`Última sincronización de esta carpeta: ${sync.formatDate(lastSync)}`));
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 remoto.`);
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(ui.c.bold(`Tenés ${ahead.length} cambio(s) local(es) sin subir:`));
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 (por tamaño) ` +
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