shilo-cli 0.10.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 +21 -0
- package/bin/shilo.js +24 -1
- package/package.json +2 -2
- package/src/ShiloClient.js +29 -0
- package/src/commands/propuestas.js +467 -0
package/README.md
CHANGED
|
@@ -186,6 +186,27 @@ 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
|
+
|
|
189
210
|
### Respaldar todas las carpetas (sync)
|
|
190
211
|
|
|
191
212
|
```bash
|
package/bin/shilo.js
CHANGED
|
@@ -25,6 +25,7 @@ ${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
|
+
propuestas [sub] Ver, bajar, aplicar o descartar propuestas (ver abajo).
|
|
28
29
|
sync [carpeta|uuid] Respalda las carpetas conectadas (--all: todas).
|
|
29
30
|
help Muestra esta ayuda.
|
|
30
31
|
|
|
@@ -118,6 +119,24 @@ ${ui.c.bold('Opciones de deploys:')}
|
|
|
118
119
|
--json Salida JSON.
|
|
119
120
|
--project <uuid> Proyecto (pisa el .shilo.json de la carpeta).
|
|
120
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
|
+
|
|
121
140
|
${ui.c.bold('Opciones de sync:')}
|
|
122
141
|
(sin argumentos) Lista las carpetas conectadas y su último sync.
|
|
123
142
|
--all Sube todas las carpetas registradas, una tras otra.
|
|
@@ -149,6 +168,8 @@ const COMMANDS = {
|
|
|
149
168
|
export: () => require('../src/commands/export'),
|
|
150
169
|
deploys: () => require('../src/commands/deploys'),
|
|
151
170
|
sync: () => require('../src/commands/sync'),
|
|
171
|
+
propuestas: () => require('../src/commands/propuestas'),
|
|
172
|
+
proposals: () => require('../src/commands/propuestas'),
|
|
152
173
|
};
|
|
153
174
|
|
|
154
175
|
async function main() {
|
|
@@ -162,11 +183,13 @@ async function main() {
|
|
|
162
183
|
'json', 'short', 'stat', 'files', 'allow-secrets', 'no-index', 'verbose',
|
|
163
184
|
'allow-failing-checks', 'all',
|
|
164
185
|
'show-ignored', 'include-ignored', 'aprobado', 'reparos', 'rechazado',
|
|
186
|
+
'todas', 'diff', 'contenido', 'forzar',
|
|
165
187
|
],
|
|
166
188
|
aliases: { h: 'help', v: 'version', m: 'message' },
|
|
167
189
|
// `--only` se repite (un selector por ruta): sin esto la última aparición
|
|
168
190
|
// pisaría a las anteriores y un push de dos archivos subiría uno solo.
|
|
169
|
-
|
|
191
|
+
// Las decisiones de `propuestas aplicar` también: una ruta por aparición.
|
|
192
|
+
multiple: ['only', 'actual', 'propuesta', 'ambas'],
|
|
170
193
|
});
|
|
171
194
|
|
|
172
195
|
const cmd = parsed._[0];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shilo-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI para sincronizar carpetas locales con repositorios de Shilo: push, pull, status, log, diff y
|
|
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",
|
package/src/ShiloClient.js
CHANGED
|
@@ -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;
|