shilo-cli 0.9.0 → 0.11.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 CHANGED
@@ -186,6 +186,62 @@ shilo deploys -n 20 --json
186
186
  `--deploy` sólo agrega el trailer `[deploy: …]` al mensaje; escribirlo a mano en
187
187
  `-m` da lo mismo.
188
188
 
189
+ ### Propuestas (propuestas)
190
+
191
+ Lo mismo que la pantalla de propuestas de la web, desde la terminal. Sirve
192
+ también para que un agente (Claude Code) lea una propuesta y la resuelva.
193
+
194
+ ```bash
195
+ shilo propuestas # las abiertas (--todas: también resueltas)
196
+ shilo propuestas ver 451391aa --diff # qué cambia y qué choca con la versión actual
197
+ shilo propuestas ver 4513 --json --contenido # todo en JSON, con el texto de cada versión
198
+ shilo propuestas bajar 4513 # base/, propuesta/ y actual/ en .shilo/propuestas/4513…/
199
+ shilo propuestas aplicar 4513 --propuesta src/a.js --actual README.md --yes
200
+ shilo propuestas aplicar 4513 --todo ambas # lo que falte decidir
201
+ shilo propuestas descartar 4513 --yes
202
+ ```
203
+
204
+ Aplicar es sólo del dueño; descartar, del dueño o de quien la propuso. En un
205
+ conflicto la decisión es por archivo (`actual`, `propuesta` o `ambas`). Para
206
+ combinar línea a línea: `bajar`, escribir el resultado en la carpeta,
207
+ `shilo push` (entra limpio sobre la versión actual) y `descartar` la vieja.
208
+ Exit code 3 = la propuesta sigue abierta.
209
+
210
+ ### Respaldar todas las carpetas (sync)
211
+
212
+ ```bash
213
+ shilo sync # lista las carpetas conectadas y su último sync
214
+ shilo sync --all # sube todas, una tras otra
215
+ shilo sync ./boda # sólo esa carpeta
216
+ shilo sync a4c2 # o por id de proyecto (alcanza un prefijo)
217
+ shilo sync --scan ~/proyectos # registra las carpetas ya conectadas que haya ahí
218
+ shilo sync --forget ./vieja # la saca del registro (no toca el disco)
219
+ shilo sync --all --dry-run # qué subiría cada una, sin subir nada
220
+ ```
221
+
222
+ El CLI lleva un registro de carpetas conectadas en `~/.shilo/carpetas.json`. Se llena
223
+ solo: cada `init`, `push` o `pull` registra su carpeta. Las que se conectaron antes
224
+ de esta versión se suman una vez con `--scan`, que busca `.shilo.json` hasta 6 niveles
225
+ abajo (no entra en `node_modules`, `.git` ni `dist`).
226
+
227
+ Sincronizar es **subir**: cada carpeta pasa por `shilo push --yes`, con sus mismas
228
+ garantías (un conflicto queda como propuesta abierta, una carpeta con posibles
229
+ secretos no sube nada). Cada push corre en su propio proceso, así que si una carpeta
230
+ falla las demás siguen, y al final hay un resumen. **Un respaldo no despliega:**
231
+ manda `--deploy off` salvo que pases otro `--deploy`.
232
+
233
+ **Una carpeta sin `base` se saltea.** `base` es la versión del proyecto sobre la que
234
+ está tu copia; la escriben `push` y `pull`. Sin ella, un push no puede saber si tu
235
+ copia está vieja y subiría tus archivos como cambios, pisando lo más nuevo. Esas
236
+ carpetas aparecen como "requiere atención": revisalas con `shilo status` y
237
+ `shilo diff`, subilas a mano una vez con `shilo push` (o bajá lo nuevo con
238
+ `shilo pull`) y desde ahí entran solas al respaldo.
239
+
240
+ Exit code 1 = alguna carpeta falló o se salteó. Una propuesta que queda esperando
241
+ aprobación (exit 3 de `push`) cuenta como respaldada. El plan de las etapas
242
+ siguientes (respaldo programado, vista en la web) está en `PLAN_SINCRONIZAR.md` en la
243
+ raíz del repo.
244
+
189
245
  ### Diferencias (diff)
190
246
 
191
247
  ```bash
@@ -326,6 +382,8 @@ SHILO_TOKEN=<pat> shilo init --project <uuid> # variable de entorno (lo natura
326
382
  `registerBlob` para binarios, camino viejo sólo como reserva.
327
383
  - **Fase 8 — deploys** *(hecho)*: `push --deploy <prod|preview|off>` y
328
384
  `shilo deploys` para el deploy automático a Vercel (PLAN_DEPLOY_VERCEL).
385
+ - **Fase 9 — respaldo** *(Etapa 1 hecha)*: registro de carpetas conectadas y
386
+ `shilo sync [--all]`. Lo que sigue, en `PLAN_SINCRONIZAR.md`.
329
387
 
330
388
  ## Exit codes
331
389
 
@@ -335,4 +393,5 @@ SHILO_TOKEN=<pat> shilo init --project <uuid> # variable de entorno (lo natura
335
393
  | `diff` | 1 | Hay diferencias entre local y remoto. |
336
394
  | `pull` | 1 | Quedaron conflictos sin bajar (o fallas). |
337
395
  | `push` | 1 | Se detectaron posibles secretos (nada subido). |
396
+ | `sync` | 1 | Alguna carpeta no se respaldó (falló o se salteó). |
338
397
  | cualquiera | 1 | Error. |
package/bin/shilo.js CHANGED
@@ -25,7 +25,9 @@ ${ui.c.bold('Comandos:')}
25
25
  review [versión] Quién revisó una versión, o dejar tu revisión.
26
26
  export <carpeta> Escribe la historia del proyecto como un repositorio Git.
27
27
  deploys Destino e historial de deploys a Vercel del proyecto.
28
- help Muestra esta ayuda.
28
+ propuestas [sub] Ver, bajar, aplicar o descartar propuestas (ver abajo).
29
+ sync [carpeta|uuid] Respalda las carpetas conectadas (--all: todas).
30
+ help Muestra esta ayuda.
29
31
 
30
32
  ${ui.c.bold('Opciones de login:')}
31
33
  --email <e> Email (si se omite, se pregunta).
@@ -117,6 +119,36 @@ ${ui.c.bold('Opciones de deploys:')}
117
119
  --json Salida JSON.
118
120
  --project <uuid> Proyecto (pisa el .shilo.json de la carpeta).
119
121
 
122
+ ${ui.c.bold('Propuestas:')}
123
+ propuestas Lista las abiertas (--todas: también las resueltas).
124
+ propuestas ver <id> Qué cambia, qué choca con la versión actual y chequeos.
125
+ --diff Muestra las diferencias de cada archivo.
126
+ --contenido Con --json, incluye el texto de cada versión (para IA).
127
+ propuestas bajar <id> Deja base/, propuesta/ y actual/ de cada archivo en
128
+ .shilo/propuestas/<id>/ (o --destino <carpeta>).
129
+ propuestas aplicar <id> Aplica (sólo el dueño). En los archivos en conflicto:
130
+ --propuesta <ruta> se queda la de la propuesta (repetible o a,b,c),
131
+ --actual <ruta> se queda la versión actual,
132
+ --ambas <ruta> quedan las dos (la propuesta al lado, con otro nombre),
133
+ --todo <decisión> lo que falte decidir: actual | propuesta | ambas.
134
+ --forzar Aplica aunque hayan fallado chequeos o piezas USEE.
135
+ propuestas descartar <id> La cierra sin aplicar (dueño o quien la propuso).
136
+ --yes --json --project <uuid>
137
+ ${ui.c.dim('El id puede ser un prefijo de 4+ caracteres. Exit code 3 = sigue abierta.')}
138
+ ${ui.c.dim('Combinar a mano: bajar, escribir el resultado, "shilo push" y descartar.')}
139
+
140
+ ${ui.c.bold('Opciones de sync:')}
141
+ (sin argumentos) Lista las carpetas conectadas y su último sync.
142
+ --all Sube todas las carpetas registradas, una tras otra.
143
+ --scan [raíz] Registra las carpetas con .shilo.json bajo raíz (default: la actual).
144
+ --forget <carpeta> La saca del registro (no toca nada en disco).
145
+ -m, --message <m> Mensaje de los commits (default: "Respaldo (shilo sync) <fecha>").
146
+ --deploy <d> prod | preview | off (default off: un respaldo no despliega).
147
+ --dry-run Muestra qué subiría cada carpeta, sin subir nada.
148
+ ${ui.c.dim('Se registran solas con init, push o pull. Una carpeta sin "base" en su')}
149
+ ${ui.c.dim('.shilo.json se saltea: subila a mano una vez con "shilo push".')}
150
+ ${ui.c.dim('Exit code 1 = alguna carpeta falló o se salteó.')}
151
+
120
152
  ${ui.c.bold('Globales:')}
121
153
  -v, --version Versión del CLI.
122
154
  -h, --help Ayuda.
@@ -135,6 +167,9 @@ const COMMANDS = {
135
167
  review: () => require('../src/commands/review'),
136
168
  export: () => require('../src/commands/export'),
137
169
  deploys: () => require('../src/commands/deploys'),
170
+ sync: () => require('../src/commands/sync'),
171
+ propuestas: () => require('../src/commands/propuestas'),
172
+ proposals: () => require('../src/commands/propuestas'),
138
173
  };
139
174
 
140
175
  async function main() {
@@ -146,13 +181,15 @@ async function main() {
146
181
  booleans: [
147
182
  'help', 'version', 'h', 'v', 'dry-run', 'yes', 'force', 'push', 'pull',
148
183
  'json', 'short', 'stat', 'files', 'allow-secrets', 'no-index', 'verbose',
149
- 'allow-failing-checks',
184
+ 'allow-failing-checks', 'all',
150
185
  'show-ignored', 'include-ignored', 'aprobado', 'reparos', 'rechazado',
186
+ 'todas', 'diff', 'contenido', 'forzar',
151
187
  ],
152
188
  aliases: { h: 'help', v: 'version', m: 'message' },
153
189
  // `--only` se repite (un selector por ruta): sin esto la última aparición
154
190
  // pisaría a las anteriores y un push de dos archivos subiría uno solo.
155
- multiple: ['only'],
191
+ // Las decisiones de `propuestas aplicar` también: una ruta por aparición.
192
+ multiple: ['only', 'actual', 'propuesta', 'ambas'],
156
193
  });
157
194
 
158
195
  const cmd = parsed._[0];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shilo-cli",
3
- "version": "0.9.0",
4
- "description": "CLI para sincronizar carpetas locales con repositorios de Shilo: push, pull, status, log y diff.",
3
+ "version": "0.11.0",
4
+ "description": "CLI para sincronizar carpetas locales con repositorios de Shilo: push, pull, status, log, diff, sync y propuestas.",
5
5
  "keywords": [
6
6
  "shilo",
7
7
  "cli",
@@ -25,7 +25,8 @@
25
25
  "README.md"
26
26
  ],
27
27
  "scripts": {
28
- "start": "node bin/shilo.js"
28
+ "start": "node bin/shilo.js",
29
+ "test": "node --test"
29
30
  },
30
31
  "engines": {
31
32
  "node": ">=18"
@@ -848,6 +848,35 @@ class ShiloClient {
848
848
  };
849
849
  }
850
850
 
851
+ /**
852
+ * Detalle de una propuesta: `changes` (con los oids `base`/`head` de cada
853
+ * archivo en servidores que los mandan), `conflicts` contra la versión
854
+ * actual de ahora, `blocked_by`, `chequeos`, `usee` y `main`. No escribe nada.
855
+ */
856
+ async getProposal(projectId, id, credentials) {
857
+ const params = new URLSearchParams({ projectId, id });
858
+ const url = `${this.baseURL}/api/projects/proposals?${params.toString()}`;
859
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials);
860
+ if (!res.ok) {
861
+ throw new ShiloError(
862
+ `No se pudo leer la propuesta: ${this._errMessage(data, `HTTP ${res.status}`)}`,
863
+ { status: res.status, body: data }
864
+ );
865
+ }
866
+ return {
867
+ proposal: (data && data.proposal) || null,
868
+ main: (data && data.main) || null,
869
+ changes: data && Array.isArray(data.changes) ? data.changes : [],
870
+ resumen: (data && data.resumen) || '',
871
+ chequeos: (data && data.chequeos) || null,
872
+ conflicts: data && Array.isArray(data.conflicts) ? data.conflicts : [],
873
+ blockedBy: data && Array.isArray(data.blocked_by) ? data.blocked_by : [],
874
+ fastForward: !!(data && data.fast_forward),
875
+ usee: (data && data.usee) || null,
876
+ credentials: creds,
877
+ };
878
+ }
879
+
851
880
  /**
852
881
  * Abre una propuesta y deja que el servidor intente aplicarla.
853
882
  *
@@ -0,0 +1,467 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const config = require('../config');
7
+ const ui = require('../ui');
8
+ const sync = require('../sync');
9
+ const prompt = require('../prompt');
10
+ const textdiff = require('../textdiff');
11
+ const { resolveContext } = require('../context');
12
+
13
+ /**
14
+ * shilo propuestas [ver|bajar|aplicar|descartar] [id] [opciones]
15
+ *
16
+ * Las propuestas desde la terminal: lo que hasta ahora sólo se veía y se
17
+ * resolvía en la web. Pensado también para que un agente (Claude Code) pueda
18
+ * leer una propuesta abierta, ver dónde choca con la versión actual y
19
+ * resolverla:
20
+ *
21
+ * - archivo por archivo con `aplicar` y --actual / --propuesta / --ambas, o
22
+ * - combinando a mano: `bajar` deja las tres versiones de cada archivo en
23
+ * `.shilo/propuestas/<id>/`, el agente escribe la combinación en la
24
+ * carpeta, `shilo push` la guarda sobre la versión actual y `descartar`
25
+ * cierra la propuesta vieja.
26
+ *
27
+ * El id es el que imprime el listado (alcanza un prefijo de 4+ caracteres).
28
+ * El contexto se resuelve siempre sobre el directorio actual, igual que `log`.
29
+ */
30
+
31
+ const SUBCOMANDOS = new Set(['listar', 'ver', 'bajar', 'aplicar', 'descartar']);
32
+ const DECISIONES = ['actual', 'propuesta', 'ambas'];
33
+ const ACCIONES = { added: 'agrega', modified: 'modifica', deleted: 'borra' };
34
+ const ESTADOS = {
35
+ abierta: (s) => ui.c.yellow(s),
36
+ aplicada: (s) => ui.c.green(s),
37
+ descartada: (s) => ui.c.dim(s),
38
+ };
39
+ // Un archivo más grande que esto no se mete en el JSON de `ver --contenido`:
40
+ // para eso está `bajar`.
41
+ const MAX_INLINE = 256 * 1024;
42
+
43
+ async function propuestas(args) {
44
+ const [primero, ...resto] = args._;
45
+ const sub = SUBCOMANDOS.has(primero) ? primero : 'listar';
46
+ const posicionales = SUBCOMANDOS.has(primero) ? resto : args._;
47
+ const id = typeof posicionales[0] === 'string' ? posicionales[0].trim() : '';
48
+
49
+ if (sub !== 'listar' && !id) {
50
+ ui.error(`Falta el id de la propuesta: shilo propuestas ${sub} <id>`);
51
+ ui.info(ui.c.dim('Los ids salen de "shilo propuestas".'));
52
+ return 1;
53
+ }
54
+
55
+ const ctx = resolveContext({ ...args, _: [] });
56
+ if (!ctx.ok) return ctx.code;
57
+
58
+ try {
59
+ if (sub === 'listar') return await listar(ctx, args);
60
+ const encontrada = await buscar(ctx, id);
61
+ if (!encontrada) return 1;
62
+ if (sub === 'ver') return await ver(ctx, encontrada, args);
63
+ if (sub === 'bajar') return await bajar(ctx, encontrada, args);
64
+ if (sub === 'aplicar') return await aplicar(ctx, encontrada, args);
65
+ return await descartar(ctx, encontrada, args);
66
+ } catch (e) {
67
+ ui.error(e.notSupported ? 'Este servidor de Shilo todavía no tiene propuestas.' : e.message);
68
+ return 1;
69
+ } finally {
70
+ config.persistCredsIfChanged(ctx.cfg, ctx.mgr.current);
71
+ }
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // listar
76
+ // ---------------------------------------------------------------------------
77
+
78
+ async function listar({ client, projectId, mgr, baseURL }, args) {
79
+ const state = args.todas ? null : 'abierta';
80
+ const listed = await client.listProposals(projectId, await mgr.valid(), { state });
81
+
82
+ if (args.json) {
83
+ console.log(JSON.stringify({
84
+ projectId, main: listed.main, viewer: listed.viewer, proposals: listed.proposals,
85
+ }, null, 2));
86
+ return 0;
87
+ }
88
+
89
+ ui.info(`Propuestas ${args.todas ? '' : 'abiertas '}— ${ui.c.cyan(baseURL)}`);
90
+ ui.info('');
91
+ if (listed.proposals.length === 0) {
92
+ ui.info(args.todas ? 'Este proyecto no tiene propuestas.' : 'No hay propuestas abiertas.');
93
+ return 0;
94
+ }
95
+ for (const p of listed.proposals) {
96
+ const estado = (ESTADOS[p.state] || ((s) => s))(p.state);
97
+ const conflictos = Array.isArray(p.conflicts) && p.conflicts.length > 0
98
+ ? ui.c.red(` ${p.conflicts.length} en conflicto`)
99
+ : '';
100
+ ui.info(`${ui.c.yellow(p.id.slice(0, 8))} ${estado} ${ui.c.bold(p.title || '(sin título)')}${conflictos}`);
101
+ ui.info(ui.c.dim(` ${sync.describeAuthor(p.author)} · ${sync.formatDate(p.created_at)}`));
102
+ }
103
+ ui.info('');
104
+ ui.info(ui.c.dim('Para verla: shilo propuestas ver <id> [--diff]'));
105
+ return 0;
106
+ }
107
+
108
+ /** La propuesta cuyo id empieza con `prefijo`, o null habiendo impreso por qué. */
109
+ async function buscar({ client, projectId, mgr }, prefijo) {
110
+ const p = prefijo.toLowerCase();
111
+ if (p.length < 4) {
112
+ ui.error('El id tiene que tener al menos 4 caracteres.');
113
+ return null;
114
+ }
115
+ const listed = await client.listProposals(projectId, await mgr.valid());
116
+ const hits = listed.proposals.filter((pr) => pr.id.toLowerCase().startsWith(p));
117
+ if (hits.length === 0) {
118
+ ui.error(`No hay ninguna propuesta que empiece con "${prefijo}".`);
119
+ return null;
120
+ }
121
+ if (hits.length > 1) {
122
+ ui.error(`"${prefijo}" coincide con ${hits.length} propuestas; usá más caracteres.`);
123
+ return null;
124
+ }
125
+ return { proposal: hits[0], viewer: listed.viewer, project: listed.project };
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // ver
130
+ // ---------------------------------------------------------------------------
131
+
132
+ async function ver(ctx, { proposal: resumen, viewer }, args) {
133
+ const { client, projectId, mgr } = ctx;
134
+ const d = await client.getProposal(projectId, resumen.id, await mgr.valid());
135
+ const p = d.proposal || resumen;
136
+ const conContenido = !!(args.diff || args.contenido);
137
+ const contenidos = conContenido ? await bajarContenidos(ctx, oidsDe(d)) : new Map();
138
+
139
+ if (args.json) {
140
+ const texto = (oid) => (args.contenido ? textoInline(contenidos, oid) : undefined);
141
+ console.log(JSON.stringify({
142
+ projectId,
143
+ proposal: p,
144
+ main: d.main,
145
+ resumen: d.resumen,
146
+ fast_forward: d.fastForward,
147
+ chequeos: d.chequeos,
148
+ usee: d.usee,
149
+ blocked_by: d.blockedBy,
150
+ puedo_aplicar: !!(viewer && viewer.is_owner),
151
+ changes: d.changes.map((c) => ({
152
+ ...c,
153
+ ...(args.contenido ? { contenido_base: texto(c.base), contenido_propuesta: texto(c.head) } : {}),
154
+ })),
155
+ // ours = la propuesta, theirs = la versión actual (ver `mergeTrees`).
156
+ conflicts: d.conflicts.map((c) => ({
157
+ path: c.path,
158
+ kind: c.kind,
159
+ base: c.base,
160
+ propuesta: c.ours,
161
+ actual: c.theirs,
162
+ ...(args.contenido
163
+ ? { contenido_base: texto(c.base), contenido_propuesta: texto(c.ours), contenido_actual: texto(c.theirs) }
164
+ : {}),
165
+ })),
166
+ }, null, 2));
167
+ return 0;
168
+ }
169
+
170
+ const estado = (ESTADOS[p.state] || ((s) => s))(p.state);
171
+ ui.info(`${ui.c.yellow(p.id.slice(0, 8))} ${estado} ${ui.c.bold(p.title || '(sin título)')}`);
172
+ ui.info(ui.c.dim(`Propuesta por ${sync.describeAuthor(p.author)} · ${sync.formatDate(p.created_at)}`));
173
+ if (p.summary) ui.info(`\n${p.summary}`);
174
+ if (d.resumen) ui.info(`\n${d.resumen}`);
175
+
176
+ ui.info('');
177
+ ui.info(ui.c.bold(`Cambios (${d.changes.length}):`));
178
+ for (const c of d.changes) ui.info(` ${ACCIONES[c.action] || c.action} ${c.path}`);
179
+
180
+ if (d.chequeos) {
181
+ const ok = d.chequeos.ok !== false;
182
+ ui.info('');
183
+ ui.info(`Chequeos: ${ok ? ui.c.green('verificada') : ui.c.red('fallaron')} en la máquina que la propuso.`);
184
+ }
185
+ if (d.usee && d.usee.despues && d.usee.despues.ok === false) {
186
+ ui.warn('Aplicarla dejaría alguna pieza USEE sin pasar el Nivel 1.');
187
+ }
188
+ if (d.blockedBy.length > 0) {
189
+ ui.info('');
190
+ for (const b of d.blockedBy) {
191
+ ui.warn(`Se cruza con la propuesta ${b.id.slice(0, 8)} "${b.title || ''}" en: ${b.paths.join(', ')}`);
192
+ }
193
+ }
194
+
195
+ if (d.conflicts.length > 0) {
196
+ ui.info('');
197
+ ui.info(ui.c.red(ui.c.bold(`En conflicto con la versión actual (${d.conflicts.length}):`)));
198
+ for (const c of d.conflicts) ui.info(` ${c.path}${c.kind === 'archivo-y-carpeta' ? ui.c.dim(' (archivo vs carpeta)') : ''}`);
199
+ } else if (p.state === 'abierta') {
200
+ ui.info('');
201
+ ui.info(ui.c.green(d.fastForward ? 'Entra tal cual sobre la versión actual.' : 'Combina limpio con la versión actual.'));
202
+ }
203
+
204
+ if (args.diff) {
205
+ const conflictivas = new Set(d.conflicts.map((c) => c.path));
206
+ // Un servidor anterior manda los cambios sin oids: sólo hay diff de los conflictos.
207
+ const conOids = d.changes.every((c) => 'head' in c);
208
+ if (!conOids && d.changes.some((c) => !conflictivas.has(c.path))) {
209
+ ui.info('');
210
+ ui.warn('Este servidor de Shilo no manda el contenido de los cambios: sólo se ven los conflictos.');
211
+ }
212
+ for (const c of conOids ? d.changes : []) {
213
+ if (conflictivas.has(c.path)) continue;
214
+ imprimirDiff(`${c.path} (base → propuesta)`, contenidos, c.base, c.head, args);
215
+ }
216
+ for (const c of d.conflicts) {
217
+ imprimirDiff(`${c.path} (versión actual → propuesta)`, contenidos, c.theirs, c.ours, args);
218
+ }
219
+ }
220
+
221
+ if (p.state === 'abierta') {
222
+ ui.info('');
223
+ const id8 = p.id.slice(0, 8);
224
+ if (!args.diff) ui.info(ui.c.dim(`Diferencias: shilo propuestas ver ${id8} --diff`));
225
+ if (viewer && viewer.is_owner) {
226
+ ui.info(ui.c.dim(d.conflicts.length > 0
227
+ ? `Resolver: shilo propuestas aplicar ${id8} --propuesta <ruta> --actual <ruta> (o --todo propuesta)`
228
+ : `Aplicar: shilo propuestas aplicar ${id8}`));
229
+ }
230
+ ui.info(ui.c.dim(`Combinar a mano: shilo propuestas bajar ${id8}`));
231
+ }
232
+ return 0;
233
+ }
234
+
235
+ function imprimirDiff(titulo, contenidos, de, a, args) {
236
+ ui.info('');
237
+ ui.info(ui.c.bold(`── ${titulo}`));
238
+ const viejo = de ? contenidos.get(de) : Buffer.alloc(0);
239
+ const nuevo = a ? contenidos.get(a) : Buffer.alloc(0);
240
+ if (viejo === undefined || nuevo === undefined) {
241
+ ui.info(ui.c.dim('(no se pudo bajar el contenido de este archivo)'));
242
+ return;
243
+ }
244
+ if (esBinario(viejo) || esBinario(nuevo)) {
245
+ ui.info(ui.c.dim(`(binario: ${viejo.length} → ${nuevo.length} bytes)`));
246
+ return;
247
+ }
248
+ const context = Number.parseInt(args.context, 10);
249
+ const r = textdiff.diffText(viejo.toString('utf8'), nuevo.toString('utf8'), {
250
+ context: Number.isFinite(context) ? Math.min(20, Math.max(0, context)) : undefined,
251
+ });
252
+ const lineas = textdiff.renderDiff(r, { paint: ui.c });
253
+ if (!a) ui.info(ui.c.red('(se borra)'));
254
+ for (const l of lineas) ui.info(l);
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // bajar
259
+ // ---------------------------------------------------------------------------
260
+
261
+ /**
262
+ * Deja en disco las versiones de cada archivo que toca la propuesta:
263
+ * propuesta/<ruta>, base/<ruta> y, si choca, actual/<ruta>.
264
+ * Por defecto en `.shilo/propuestas/<id8>/`, que el push ignora.
265
+ */
266
+ async function bajar(ctx, { proposal: resumen }, args) {
267
+ const { client, projectId, mgr, dir } = ctx;
268
+ const d = await client.getProposal(projectId, resumen.id, await mgr.valid());
269
+ const destino = path.resolve(
270
+ typeof args.destino === 'string' ? args.destino : path.join(dir, '.shilo', 'propuestas', resumen.id.slice(0, 8))
271
+ );
272
+ const contenidos = await bajarContenidos(ctx, oidsDe(d));
273
+
274
+ const escritos = [];
275
+ const escribir = (lado, ruta, oid) => {
276
+ if (!oid) return;
277
+ const buf = contenidos.get(oid);
278
+ if (buf === undefined) return;
279
+ const abs = path.join(destino, lado, ...ruta.split('/'));
280
+ if (!abs.startsWith(destino + path.sep)) return; // ruta rara: no salir de destino
281
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
282
+ fs.writeFileSync(abs, buf);
283
+ escritos.push(path.relative(destino, abs));
284
+ };
285
+ for (const c of d.changes) {
286
+ escribir('base', c.path, c.base);
287
+ escribir('propuesta', c.path, c.head);
288
+ }
289
+ for (const c of d.conflicts) {
290
+ escribir('base', c.path, c.base);
291
+ escribir('propuesta', c.path, c.ours);
292
+ escribir('actual', c.path, c.theirs);
293
+ }
294
+
295
+ const informe = {
296
+ id: resumen.id,
297
+ title: resumen.title,
298
+ main: d.main,
299
+ changes: d.changes.map((c) => ({ path: c.path, action: c.action })),
300
+ conflicts: d.conflicts.map((c) => ({ path: c.path, kind: c.kind })),
301
+ };
302
+ fs.mkdirSync(destino, { recursive: true });
303
+ fs.writeFileSync(path.join(destino, 'propuesta.json'), JSON.stringify(informe, null, 2));
304
+
305
+ if (args.json) {
306
+ console.log(JSON.stringify({ ...informe, destino, archivos: escritos }, null, 2));
307
+ return 0;
308
+ }
309
+ if (!d.changes.every((c) => 'head' in c)) {
310
+ ui.warn('Este servidor de Shilo no manda el contenido de los cambios: sólo se bajaron los conflictos.');
311
+ }
312
+ ui.success(`${escritos.length} archivo(s) en ${destino}`);
313
+ ui.info(ui.c.dim(' base/ como estaba cuando se propuso'));
314
+ ui.info(ui.c.dim(' propuesta/ lo que propone'));
315
+ if (d.conflicts.length > 0) ui.info(ui.c.dim(' actual/ la versión actual, en los archivos que chocan'));
316
+ ui.info('');
317
+ ui.info('Para combinar a mano: escribí el resultado en tu carpeta, "shilo push",');
318
+ ui.info(`y después "shilo propuestas descartar ${resumen.id.slice(0, 8)}".`);
319
+ return 0;
320
+ }
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // aplicar / descartar
324
+ // ---------------------------------------------------------------------------
325
+
326
+ /**
327
+ * Arma `{ ruta: decisión }` de --actual / --propuesta / --ambas (repetibles,
328
+ * también separados por coma) y --todo <decisión> para el resto. Devuelve
329
+ * `{ resolutions, faltan }` o `{ error }`.
330
+ */
331
+ function armarDecisiones(args, conflicts) {
332
+ const resolutions = {};
333
+ for (const decision of DECISIONES) {
334
+ for (const v of args[decision] || []) {
335
+ for (const ruta of String(v).split(',').map((s) => s.trim().replace(/^\.?\//, '')).filter(Boolean)) {
336
+ if (resolutions[ruta] && resolutions[ruta] !== decision) {
337
+ return { error: `"${ruta}" tiene dos decisiones: ${resolutions[ruta]} y ${decision}.` };
338
+ }
339
+ resolutions[ruta] = decision;
340
+ }
341
+ }
342
+ }
343
+ const todo = typeof args.todo === 'string' ? args.todo : null;
344
+ if (args.todo !== undefined && !DECISIONES.includes(todo)) {
345
+ return { error: `--todo tiene que ser ${DECISIONES.join(', ')}.` };
346
+ }
347
+ const enConflicto = new Set(conflicts.map((c) => c.path));
348
+ const ajenas = Object.keys(resolutions).filter((r) => !enConflicto.has(r));
349
+ if (ajenas.length > 0) {
350
+ return { error: `No están en conflicto: ${ajenas.join(', ')}. Mirá "shilo propuestas ver".` };
351
+ }
352
+ for (const c of conflicts) {
353
+ if (!resolutions[c.path] && todo) resolutions[c.path] = todo;
354
+ }
355
+ const faltan = conflicts.map((c) => c.path).filter((r) => !resolutions[r]);
356
+ return { resolutions, faltan };
357
+ }
358
+
359
+ async function aplicar(ctx, { proposal: resumen, viewer }, args) {
360
+ const { client, projectId, mgr } = ctx;
361
+ if (resumen.state !== 'abierta') {
362
+ ui.error(`Esa propuesta ya está ${resumen.state}.`);
363
+ return 1;
364
+ }
365
+ if (viewer && !viewer.is_owner) {
366
+ ui.error('Sólo el dueño del proyecto puede aplicar una propuesta.');
367
+ return 1;
368
+ }
369
+
370
+ const d = await client.getProposal(projectId, resumen.id, await mgr.valid());
371
+ const armado = armarDecisiones(args, d.conflicts);
372
+ if (armado.error) {
373
+ ui.error(armado.error);
374
+ return 1;
375
+ }
376
+ if (armado.faltan.length > 0) {
377
+ ui.error(`Falta decidir ${armado.faltan.length} archivo(s) en conflicto:`);
378
+ for (const r of armado.faltan) ui.info(` ${r}`);
379
+ ui.info(ui.c.dim('Usá --actual <ruta>, --propuesta <ruta>, --ambas <ruta> o --todo <decisión>.'));
380
+ return 1;
381
+ }
382
+
383
+ const id8 = resumen.id.slice(0, 8);
384
+ if (!args.yes) {
385
+ for (const [ruta, dec] of Object.entries(armado.resolutions)) ui.info(` ${ruta}: ${dec}`);
386
+ if (!(await prompt.confirm(`¿Aplicar la propuesta ${id8} "${resumen.title || ''}"?`))) {
387
+ ui.info('No se aplicó nada.');
388
+ return 1;
389
+ }
390
+ }
391
+
392
+ const r = await client.resolveProposal(projectId, resumen.id, 'apply', await mgr.valid(), {
393
+ resolutions: Object.keys(armado.resolutions).length > 0 ? armado.resolutions : null,
394
+ force: !!args.forzar,
395
+ });
396
+
397
+ if (args.json) {
398
+ console.log(JSON.stringify({ projectId, applied: r.applied, proposal: r.proposal, conflicts: r.conflicts, piezas: r.piezas, main: r.main, message: r.message }, null, 2));
399
+ return r.applied ? 0 : 3;
400
+ }
401
+ if (r.applied) {
402
+ ui.success(`Propuesta ${id8} aplicada: ya es la versión actual.`);
403
+ ui.info(ui.c.dim('Traela a tu carpeta con "shilo pull".'));
404
+ return 0;
405
+ }
406
+ ui.warn(r.message || 'La propuesta sigue abierta.');
407
+ if (r.conflicts.length > 0) for (const c of r.conflicts) ui.info(` ${c.path}`);
408
+ if (!args.forzar && (r.piezas.length > 0 || /forzar/.test(r.message))) {
409
+ ui.info(ui.c.dim('Para aplicarla igual: --forzar'));
410
+ }
411
+ return 3;
412
+ }
413
+
414
+ async function descartar(ctx, { proposal: resumen }, args) {
415
+ const { client, projectId, mgr } = ctx;
416
+ if (resumen.state !== 'abierta') {
417
+ ui.error(`Esa propuesta ya está ${resumen.state}.`);
418
+ return 1;
419
+ }
420
+ const id8 = resumen.id.slice(0, 8);
421
+ if (!args.yes && !(await prompt.confirm(`¿Descartar la propuesta ${id8} "${resumen.title || ''}"?`))) {
422
+ ui.info('No se descartó nada.');
423
+ return 1;
424
+ }
425
+ await client.resolveProposal(projectId, resumen.id, 'discard', await mgr.valid());
426
+ if (args.json) console.log(JSON.stringify({ projectId, id: resumen.id, discarded: true }));
427
+ else ui.success(`Propuesta ${id8} descartada.`);
428
+ return 0;
429
+ }
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // Contenido por oid
433
+ // ---------------------------------------------------------------------------
434
+
435
+ function oidsDe(d) {
436
+ const oids = new Set();
437
+ for (const c of d.changes) [c.base, c.head].forEach((o) => o && oids.add(o));
438
+ for (const c of d.conflicts) [c.base, c.ours, c.theirs].forEach((o) => o && oids.add(o));
439
+ return [...oids];
440
+ }
441
+
442
+ /** `oid → Buffer`, verificado contra su sha256. Los que no llegan, no están. */
443
+ async function bajarContenidos({ client, projectId, mgr }, oids) {
444
+ const out = new Map();
445
+ if (oids.length === 0) return out;
446
+ const r = await client.graphBlobs(projectId, oids, await mgr.valid());
447
+ await sync.runPool(Object.entries(r.blobs), 6, async ([oid, { url }]) => {
448
+ const buf = url ? await client.downloadBinary(url) : Buffer.alloc(0);
449
+ if (crypto.createHash('sha256').update(buf).digest('hex') === oid) out.set(oid, buf);
450
+ });
451
+ return out;
452
+ }
453
+
454
+ function esBinario(buf) {
455
+ return buf.subarray(0, 8000).includes(0);
456
+ }
457
+
458
+ /** El texto de un oid para el JSON, o null si no hay, es binario o es grande. */
459
+ function textoInline(contenidos, oid) {
460
+ if (!oid) return null;
461
+ const buf = contenidos.get(oid);
462
+ if (!buf || esBinario(buf) || buf.length > MAX_INLINE) return null;
463
+ return buf.toString('utf8');
464
+ }
465
+
466
+ module.exports = propuestas;
467
+ module.exports.armarDecisiones = armarDecisiones;
@@ -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 };
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